Skip to content
SlashClone

Retention cohorts

@usagemetrics/retention-cohorts

Build weekly or monthly retention cohorts for users or accounts from an events CSV. Writes the cohort table, a size-weighted average curve, where the curve flattens, and the best and worst cohorts, as markdown, CSV, and JSON.

SkillDatav1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Retention cohorts from an events CSV.
  3. 3// Usage: node scripts/cohorts.mjs <events.csv> [--period week|month] [--entity user|account] [--periods 12]
  4. 4// [--active-events ran_query,created_report] [--as-of 2026-10-30] [--include-current] [--out out/retention]
  5. 5// Columns: timestamp, account_id, user_id, event.
  6. 6import { DAY_MS, dayKey, fail, mdTable, monthKey, parseArgs, parseDate, readCsv, round, toCsv, weekKey, writeOut } from "./lib.mjs";
  7. 7
  8. 8const args = parseArgs(process.argv.slice(2), ["include-current"]);
  9. 9const input = args._[0];
  10. 10if (!input) fail("usage: node scripts/cohorts.mjs <events.csv> [--period week|month] [--entity user|account]");
  11. 11const period = args.period || "month";
  12. 12if (!["week", "month"].includes(period)) fail("--period must be week or month.");
  13. 13const entity = args.entity || "user";
  14. 14if (!["user", "account"].includes(entity)) fail("--entity must be user or account.");
  15. 15const maxPeriods = Number(args.periods ?? 12);
  16. 16const outPrefix = args.out || "out/retention";
  17. 17const activeEvents = args["active-events"] ? new Set(String(args["active-events"]).split(",").map((s) => s.trim().toLowerCase())) : null;
  18. 19const rows = readCsv(input, "events CSV", ["timestamp", entity === "user" ? "user_id" : "account_id", "event"]);
  19. 20const MONDAY0 = Date.UTC(1970, 0, 5);
  20. 21const keyOf = (d) => (period === "week" ? weekKey(d) : monthKey(d));
  21. 22function index(key) {
  22. 23 if (period === "month") {
  23. 24 const [y, m] = key.split("-").map(Number);
  24. 25 return y * 12 + (m - 1);
  25. 26 }
  26. 27 return Math.round((Date.parse(key) - MONDAY0) / (7 * DAY_MS));
  27. 29function labelOf(i) {
  28. 30 if (period === "month") return `${Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, "0")}`;
  29. 31 return dayKey(new Date(MONDAY0 + i * 7 * DAY_MS));
  30. 34const first = new Map();
  31. 35const activeIn = new Map();
  32. 36let maxDate = null;
  33. 37let skipped = 0;
  34. 38for (const r of rows) {
  35. 39 const d = parseDate(r.timestamp);
  36. 40 const id = entity === "user" ? r.user_id : r.account_id;
  37. 41 if (!d || !id) {
  38. 42 skipped++;
  39. 43 continue;
  40. 44 }
  41. 45 if (!maxDate || d > maxDate) maxDate = d;
  42. 46 const i = index(keyOf(d));
  43. 47 // First seen counts any event; "active" can be limited to meaningful events.
  44. 48 if (!first.has(id) || i < first.get(id)) first.set(id, i);
  45. 49 if (activeEvents && !activeEvents.has((r.event || "").toLowerCase())) continue;
  46. 50 if (!activeIn.has(id)) activeIn.set(id, new Set());
  47. 51 activeIn.get(id).add(i);
  48. 53if (!first.size) fail(`no usable rows in ${input}.`);
  49. 54const asOf = args["as-of"] ? parseDate(args["as-of"]) : maxDate;
  50. 55const current = index(keyOf(asOf));
  51. 56const lastComplete = args["include-current"] ? current : current - 1;
  52. 58const cohorts = new Map();
  53. 59for (const [id, i] of first) {
  54. 60 if (i > lastComplete) continue;
  55. 61 if (!cohorts.has(i)) cohorts.set(i, []);
  56. 62 cohorts.get(i).push(id);
  57. 64const keys = [...cohorts.keys()].sort((a, b) => a - b).slice(-maxPeriods);
  58. 65const table = keys.map((c) => {
  59. 66 const ids = cohorts.get(c);
  60. 67 const counts = [];
  61. 68 for (let k = 0; k < maxPeriods; k++) {
  62. 69 if (c + k > lastComplete) break;
  63. 70 counts.push(ids.filter((id) => activeIn.get(id)?.has(c + k)).length);
  64. 71 }
  65. 72 return { cohort: labelOf(c), size: ids.length, retained: counts, rates: counts.map((n) => round(n / ids.length, 4)) };
  66. 73});
  67. 75// Weighted average curve: only cohorts old enough to have each period.
  68. 76const curve = [];
  69. 77for (let k = 0; k < maxPeriods; k++) {
  70. 78 let n = 0;
  71. 79 let d = 0;
  72. 80 for (const row of table)
  73. 81 if (row.retained.length > k) {
  74. 82 n += row.retained[k];
  75. 83 d += row.size;
  76. 84 }
  77. 85 if (!d) break;
  78. 86 curve.push({ period: k, rate: round(n / d, 4), cohorts: table.filter((r) => r.retained.length > k).length });
  79. 88let flattensAt = null;
  80. 89// Flat = less than 2 points lost per period for two periods in a row, each averaged over 3+ cohorts.
  81. 90for (let k = 2; k + 1 < curve.length; k++)
  82. 91 if (curve[k + 1].cohorts >= 3 && curve[k - 1].rate - curve[k].rate < 0.02 && curve[k].rate - curve[k + 1].rate < 0.02) {
  83. 92 flattensAt = k - 1;
  84. 93 break;
  85. 94 }
  86. 95const p1 = table.filter((r) => r.rates.length > 1 && r.size >= 3);
  87. 96const best = p1.length ? p1.reduce((a, b) => (b.rates[1] > a.rates[1] ? b : a)) : null;
  88. 97const worst = p1.length ? p1.reduce((a, b) => (b.rates[1] < a.rates[1] ? b : a)) : null;
  89. 99const unit = period === "week" ? "W" : "M";
  90. 100const result = {
  91. 101 source: input,
  92. 102 period,
  93. 103 entity,
  94. 104 active_definition: activeEvents ? `any of: ${[...activeEvents].join(", ")}` : "any event",
  95. 105 as_of: dayKey(asOf),
  96. 106 last_complete_period: labelOf(lastComplete),
  97. 107 cohorts: table,
  98. 108 average_curve: curve,
  99. 109 flattens_at: flattensAt,
  100. 110 best_period1: best && { cohort: best.cohort, rate: best.rates[1] },
  101. 111 worst_period1: worst && { cohort: worst.cohort, rate: worst.rates[1] },
  102. 112 skipped_rows: skipped,
  103. 113};
  104. 114writeOut(`${outPrefix}.json`, JSON.stringify(result, null, 2) + "\n");
  105. 115const cols = Array.from({ length: maxPeriods }, (_, k) => `${unit}${k}`);
  106. 116writeOut(
  107. 117 `${outPrefix}.csv`,
  108. 118 toCsv(
  109. 119 table.map((r) => ({ cohort: r.cohort, size: r.size, ...Object.fromEntries(r.rates.map((v, k) => [`${unit}${k}`, v])) })),
  110. 120 ["cohort", "size", ...cols.slice(0, Math.max(...table.map((r) => r.rates.length), 1))],
  111. 121 ),
  112. 122);
  113. 124const width = Math.max(...table.map((r) => r.rates.length), 1);
  114. 125const pctCell = (v) => (v === undefined ? "" : `${Math.round(v * 100)}%`);
  115. 126const md = [
  116. 127 `# ${entity === "user" ? "User" : "Account"} retention by ${period}ly cohort`,
  117. 128 "",
  118. 129 `Active means ${result.active_definition}. Cohort = ${period} of first event. As of ${result.as_of}; the current ${period} is ${args["include-current"] ? "included (partial)" : "left out because it is not complete"}.`,
  119. 130 "",
  120. 131 mdTable(["Cohort", "Size", ...cols.slice(0, width)], table.map((r) => [r.cohort, r.size, ...Array.from({ length: width }, (_, k) => pctCell(r.rates[k]))])),
  121. 132 "",
  122. 133 "## Average curve (weighted by cohort size)",
  123. 134 "",
  124. 135 mdTable(["Period", "Retained", "Cohorts in average"], curve.map((c) => [`${unit}${c.period}`, pctCell(c.rate), c.cohorts])),
  125. 136 "",
  126. 137 flattensAt !== null ? `The curve flattens around ${unit}${flattensAt} (less than 2 points lost per ${period} for two ${period}s in a row), at about ${pctCell(curve[flattensAt].rate)}.` : "The curve has not flattened yet in this data.",
  127. 138 best && worst && best !== worst ? `Best ${unit}1: ${best.cohort} (${pctCell(best.rates[1])}). Worst ${unit}1: ${worst.cohort} (${pctCell(worst.rates[1])}).` : "",
  128. 139 "",
  129. 140].join("\n");
  130. 141writeOut(`${outPrefix}.md`, md);
  131. 143process.stdout.write(`Wrote ${outPrefix}.md, ${outPrefix}.csv, ${outPrefix}.json\n`);
  132. 144process.stdout.write(`${table.length} cohorts. ${unit}1 average ${curve[1] ? pctCell(curve[1].rate) : "n/a"}${flattensAt !== null ? `, flattens around ${unit}${flattensAt}` : ""}.\n`);