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