Skip to content
SlashClone

Activation funnel

@usagemetrics/activation-funnel

Measure an activation funnel from an events CSV: how many users or accounts reach each step within a time window, conversion step to step, median time between steps, the biggest drop, and splits by plan or any segment. Writes markdown, CSV, JSON, and a list of who got stuck.

SkillDatav1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Activation funnel from an events CSV.
  3. 3// Usage: node scripts/funnel.mjs <events.csv> --steps signed_up,connected_source,created_report,shared_dashboard
  4. 4// [--window-days 14] [--entity user|account] [--any-order] [--include-recent] [--as-of 2026-10-30]
  5. 5// [--from 2026-08-01] [--to 2026-09-30] [--segments data/accounts.csv --segment-column plan] [--out out/funnel]
  6. 6// Columns: timestamp, account_id, user_id, event.
  7. 7import { DAY_MS, dayKey, fail, mdTable, median, parseArgs, parseDate, readCsv, round, toCsv, writeOut } from "./lib.mjs";
  8. 8
  9. 9const args = parseArgs(process.argv.slice(2), ["any-order", "include-recent"]);
  10. 10const input = args._[0];
  11. 11if (!input || !args.steps) fail("usage: node scripts/funnel.mjs <events.csv> --steps step1,step2,step3 [--window-days 14]");
  12. 12const steps = String(args.steps).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
  13. 13if (steps.length < 2) fail("--steps needs at least two events, in order.");
  14. 14const windowDays = Number(args["window-days"] ?? 14);
  15. 15const entity = args.entity || "user";
  16. 16const ordered = !args["any-order"];
  17. 17const outPrefix = args.out || "out/funnel";
  18. 19const rows = readCsv(input, "events CSV", ["timestamp", "event", entity === "user" ? "user_id" : "account_id"]);
  19. 20const byEntity = new Map();
  20. 21const accountOf = new Map();
  21. 22let maxT = 0;
  22. 23for (const r of rows) {
  23. 24 const d = parseDate(r.timestamp);
  24. 25 const id = entity === "user" ? r.user_id : r.account_id;
  25. 26 if (!d || !id) continue;
  26. 27 const ev = (r.event || "").toLowerCase();
  27. 28 maxT = Math.max(maxT, d.getTime());
  28. 29 if (r.account_id) accountOf.set(id, r.account_id);
  29. 30 if (!steps.includes(ev)) continue;
  30. 31 if (!byEntity.has(id)) byEntity.set(id, []);
  31. 32 byEntity.get(id).push({ t: d.getTime(), ev });
  32. 34const asOf = args["as-of"] ? parseDate(args["as-of"])?.getTime() + DAY_MS - 1 : maxT;
  33. 35const from = args.from ? parseDate(args.from)?.getTime() : -Infinity;
  34. 36const to = args.to ? parseDate(args.to)?.getTime() + DAY_MS - 1 : Infinity;
  35. 38let segmentOf = null;
  36. 39if (args.segments) {
  37. 40 const col = (args["segment-column"] || "segment").toLowerCase();
  38. 41 const seg = readCsv(args.segments, "segments CSV", ["account_id", col]);
  39. 42 const map = new Map(seg.map((s) => [s.account_id, s[col] || "(blank)"]));
  40. 43 segmentOf = (id) => map.get(entity === "account" ? id : accountOf.get(id)) || "(unknown)";
  41. 46// Walk each entity through the steps.
  42. 47const journeys = [];
  43. 48let tooRecent = 0;
  44. 49for (const [id, evs] of byEntity) {
  45. 50 evs.sort((a, b) => a.t - b.t);
  46. 51 const start = evs.find((e) => e.ev === steps[0]);
  47. 52 if (!start || start.t < from || start.t > to) continue;
  48. 53 if (!args["include-recent"] && start.t + windowDays * DAY_MS > asOf) {
  49. 54 tooRecent++;
  50. 55 continue;
  51. 56 }
  52. 57 const deadline = start.t + windowDays * DAY_MS;
  53. 58 const times = [start.t];
  54. 59 let prevT = start.t;
  55. 60 for (let i = 1; i < steps.length; i++) {
  56. 61 const hit = evs.find((e) => e.ev === steps[i] && e.t <= deadline && (ordered ? e.t >= prevT : e.t >= start.t));
  57. 62 if (!hit) break;
  58. 63 times.push(hit.t);
  59. 64 prevT = hit.t;
  60. 65 }
  61. 66 journeys.push({ id, times, segment: segmentOf ? segmentOf(id) : null });
  62. 68if (!journeys.length) fail(`no ${entity}s did "${steps[0]}" in the chosen range (and at least ${windowDays} days before the as-of date). Try --include-recent or check the step names.`);
  63. 70function funnel(list) {
  64. 71 const n0 = list.length;
  65. 72 return steps.map((s, i) => {
  66. 73 const reached = list.filter((j) => j.times.length > i);
  67. 74 const prev = i === 0 ? n0 : list.filter((j) => j.times.length > i - 1).length;
  68. 75 const hrsFromPrev = i === 0 ? [] : reached.map((j) => (j.times[i] - j.times[i - 1]) / 3600000);
  69. 76 const hrsFromStart = reached.map((j) => (j.times[i] - j.times[0]) / 3600000);
  70. 77 return {
  71. 78 step: s,
  72. 79 reached: reached.length,
  73. 80 pct_of_start: n0 ? round(reached.length / n0, 4) : 0,
  74. 81 pct_of_previous: prev ? round(reached.length / prev, 4) : 0,
  75. 82 dropped: prev - reached.length,
  76. 83 median_hours_from_previous: i === 0 || !hrsFromPrev.length ? null : round(median(hrsFromPrev), 1),
  77. 84 median_hours_from_start: i === 0 || !hrsFromStart.length ? null : round(median(hrsFromStart), 1),
  78. 85 };
  79. 86 });
  80. 88const overall = funnel(journeys);
  81. 89const biggest = overall.slice(1).reduce((a, b) => (b.pct_of_previous < a.pct_of_previous ? b : a));
  82. 90const segments = {};
  83. 91if (segmentOf) for (const s of [...new Set(journeys.map((j) => j.segment))].sort()) segments[s] = funnel(journeys.filter((j) => j.segment === s));
  84. 93const result = {
  85. 94 source: input,
  86. 95 entity,
  87. 96 steps,
  88. 97 window_days: windowDays,
  89. 98 ordered,
  90. 99 as_of: dayKey(new Date(asOf)),
  91. 100 entered: journeys.length,
  92. 101 excluded_too_recent: tooRecent,
  93. 102 completed: overall[overall.length - 1].reached,
  94. 103 conversion: overall[overall.length - 1].pct_of_start,
  95. 104 biggest_drop: { into_step: biggest.step, pct_of_previous: biggest.pct_of_previous, dropped: biggest.dropped },
  96. 105 funnel: overall,
  97. 106 segments,
  98. 107};
  99. 108writeOut(`${outPrefix}.json`, JSON.stringify(result, null, 2) + "\n");
  100. 109const flat = [];
  101. 110for (const [seg, f] of [["all", overall], ...Object.entries(segments)]) for (const s of f) flat.push({ segment: seg, ...s });
  102. 111writeOut(`${outPrefix}.csv`, toCsv(flat, ["segment", "step", "reached", "pct_of_start", "pct_of_previous", "dropped", "median_hours_from_previous", "median_hours_from_start"]));
  103. 112// Who got stuck at the biggest drop (IDs only, for follow-up lists).
  104. 113const stuckIndex = steps.indexOf(biggest.step);
  105. 114writeOut(`${outPrefix}-stuck.csv`, toCsv(journeys.filter((j) => j.times.length === stuckIndex).map((j) => ({ id: j.id, account_id: accountOf.get(j.id) || "", last_step: steps[stuckIndex - 1], started: dayKey(new Date(j.times[0])) })), ["id", "account_id", "last_step", "started"]));
  106. 116const p = (v) => `${Math.round(v * 100)}%`;
  107. 117const dur = (h) => (h === null ? "" : h < 48 ? `${h.toFixed(1)} h` : `${(h / 24).toFixed(1)} d`);
  108. 118const funnelTable = (f) => mdTable(["Step", "Reached", "% of start", "% of previous", "Dropped", "Median time from previous"], f.map((s) => [s.step, s.reached, p(s.pct_of_start), p(s.pct_of_previous), s.dropped, dur(s.median_hours_from_previous)]));
  109. 119const md = [
  110. 120 `# Activation funnel (${entity}s, ${windowDays}-day window${ordered ? ", steps in order" : ", any order"})`,
  111. 121 "",
  112. 122 `${journeys.length} ${entity}s started with "${steps[0]}"; ${result.completed} (${p(result.conversion)}) reached "${steps[steps.length - 1]}" within ${windowDays} days.${tooRecent ? ` ${tooRecent} started less than ${windowDays} days before ${result.as_of} and are left out.` : ""}`,
  113. 123 "",
  114. 124 funnelTable(overall),
  115. 125 "",
  116. 126 `Biggest drop: into "${biggest.step}" (${p(biggest.pct_of_previous)} of the previous step made it; ${biggest.dropped} dropped). Their IDs are in \`${outPrefix}-stuck.csv\`.`,
  117. 127 "",
  118. 128 ...Object.entries(segments).flatMap(([seg, f]) => [`## Segment: ${seg} (${f[0].reached} started, ${p(f[f.length - 1].pct_of_start)} completed)`, "", funnelTable(f), ""]),
  119. 129].join("\n");
  120. 130writeOut(`${outPrefix}.md`, md);
  121. 132process.stdout.write(`Wrote ${outPrefix}.md, ${outPrefix}.csv, ${outPrefix}.json, ${outPrefix}-stuck.csv\n`);
  122. 133process.stdout.write(`${journeys.length} started, ${p(result.conversion)} completed. Biggest drop into "${biggest.step}" (${p(biggest.pct_of_previous)}).\n`);