返回 CodeWhale
report-active-installs.mjs
根目录 / telemetry-ingest / scripts / report-active-installs.mjs
1 #!/usr/bin/env node
2
3 /**
4 * Owner report: observed active installs.
5 *
6 * The metric is **observed active installs** — distinct rotating anonymous
7 * install ids that produced a `session_start` event on a UTC day. It is never
8 * a count of people, accounts, or total installs, and the report says so next
9 * to every number it prints. `report-dau.mjs` is the compatibility entry
10 * point; this file is the canonical one.
11 */
12
13 import { pathToFileURL } from "node:url";
14
15 const SQL_ENDPOINT = (accountId) =>
16 `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/analytics_engine/sql`;
17
18 const DAY_MS = 86_400_000;
19
20 /**
21 * Printed with the numbers, always — a report copy-pasted into a chat carries
22 * its own caveats. Wording is pinned by test/report-active-installs.test.ts.
23 */
24 export const COVERAGE_CAVEATS = [
25 "Observed active installs = distinct rotating anonymous install ids with a session_start that UTC day. Not people, not accounts, not total installs.",
26 "Coverage is a lower bound: clients older than the telemetry feature, opted-out installs, and non-emitting environments (kill switches, fleet workers, offline shutdowns, dropped flushes) are invisible.",
27 "Install ids rotate every 90 days and are deleted on opt-out, so week-over-week comparisons are not a retention metric.",
28 ];
29
30 export function parseArgs(argv) {
31 // 15 = 14 complete UTC days plus the partial current day, the smallest
32 // window that fills both sides of the 7-day trend.
33 let days = 15;
34 let json = false;
35 for (let index = 0; index < argv.length; index += 1) {
36 const arg = argv[index];
37 if (arg === "--json") {
38 json = true;
39 continue;
40 }
41 if (arg === "--days") {
42 const raw = argv[index + 1];
43 index += 1;
44 days = Number(raw);
45 continue;
46 }
47 throw new Error(`unknown argument: ${arg}`);
48 }
49 if (!Number.isInteger(days) || days < 1 || days > 90) {
50 throw new Error("--days must be an integer from 1 through 90");
51 }
52 return { days, json };
53 }
54
55 export function activeInstallsSql(days) {
56 return `SELECT
57 toDate(timestamp) AS day,
58 count(DISTINCT index1) AS active_installs,
59 sum(_sample_interval) AS sessions_started
60 FROM codewhale_telemetry
61 WHERE timestamp >= toStartOfDay(NOW()) - INTERVAL '${days - 1}' DAY
62 AND blob1 = 'session_start'
63 GROUP BY day
64 ORDER BY day DESC
65 FORMAT JSON`;
66 }
67
68 /** Newest ingested event of any kind — how stale the dataset is. */
69 export function freshnessSql() {
70 return `SELECT
71 max(timestamp) AS newest_event
72 FROM codewhale_telemetry
73 FORMAT JSON`;
74 }
75
76 function dataRows(payload) {
77 const rows = Array.isArray(payload) ? payload : payload?.data;
78 if (!Array.isArray(rows)) {
79 throw new Error("Cloudflare SQL response did not contain a data array");
80 }
81 return rows;
82 }
83
84 export function rowsFromResponse(payload) {
85 return dataRows(payload).map((row) => ({
86 day: String(row.day),
87 active_installs: Number(row.active_installs),
88 sessions_started: Number(row.sessions_started),
89 }));
90 }
91
92 /** `null` when the dataset has no rows at all. */
93 export function newestEventFromResponse(payload) {
94 const raw = dataRows(payload)[0]?.newest_event;
95 if (raw === undefined || raw === null || String(raw).startsWith("0000")) {
96 return null;
97 }
98 // The SQL API returns "YYYY-MM-DD HH:MM:SS" in UTC without a zone marker.
99 const normalized = /[zZ]|[+-]\d{2}:\d{2}$/.test(String(raw))
100 ? String(raw)
101 : `${String(raw).replace(" ", "T")}Z`;
102 const parsed = new Date(normalized);
103 return Number.isNaN(parsed.getTime()) ? null : parsed;
104 }
105
106 export function formatAge(ms) {
107 const minutes = Math.max(0, Math.floor(ms / 60_000));
108 const days = Math.floor(minutes / 1440);
109 const hours = Math.floor((minutes % 1440) / 60);
110 const mins = minutes % 60;
111 if (days > 0) return `${days}d ${hours}h`;
112 if (hours > 0) return `${hours}h ${mins}m`;
113 return `${mins}m`;
114 }
115
116 /**
117 * Week-over-week trend over complete UTC days only — the partial current day
118 * never enters either window. Sums of daily observed active installs, not
119 * weekly uniques: the same id active on two days counts twice, deliberately,
120 * because a distinct-over-the-week count would look like retention and the id
121 * rotation makes retention unmeasurable here.
122 */
123 export function trendSummary(rows, days, now = new Date()) {
124 const completeDays = days - 1; // the query window includes the partial today
125 const byDay = new Map(rows.map((row) => [row.day, row.active_installs]));
126 const todayStart = Date.UTC(
127 now.getUTCFullYear(),
128 now.getUTCMonth(),
129 now.getUTCDate(),
130 );
131 const sumWindow = (startOffset) => {
132 let total = 0;
133 for (let i = 0; i < 7; i += 1) {
134 const day = new Date(todayStart - (startOffset + i) * DAY_MS)
135 .toISOString()
136 .slice(0, 10);
137 total += byDay.get(day) ?? 0;
138 }
139 return total;
140 };
141 const last7 = completeDays >= 7 ? sumWindow(1) : null;
142 const previous7 = completeDays >= 14 ? sumWindow(8) : null;
143 let changePct = null;
144 if (last7 !== null && previous7 !== null && previous7 > 0) {
145 changePct = Math.round(((last7 - previous7) / previous7) * 1000) / 10;
146 }
147 return { last7, previous7, changePct };
148 }
149
150 export function formatReport(rows, { days, now = new Date(), newestEvent = null } = {}) {
151 const today = now.toISOString().slice(0, 10);
152 const lines = [
153 "Codewhale observed active installs (UTC)",
154 "day active installs sessions started",
155 ];
156 for (const row of rows) {
157 const day = row.day === today ? `${row.day}*` : row.day;
158 lines.push(
159 `${day.padEnd(12)} ${String(row.active_installs).padStart(15)} ${String(row.sessions_started).padStart(18)}`,
160 );
161 }
162 lines.push("", "* current UTC day is partial");
163
164 const trend = trendSummary(rows, days, now);
165 lines.push("", "7-day trend (complete UTC days; sums of daily observed active installs):");
166 if (trend.last7 === null) {
167 lines.push(" window too small for a 7-day trend — use --days 15 or more");
168 } else {
169 lines.push(` last 7 days: ${trend.last7}`);
170 if (trend.previous7 === null) {
171 lines.push(" previous 7 days: not covered — use --days 15 or more");
172 } else {
173 lines.push(` previous 7 days: ${trend.previous7}`);
174 const change =
175 trend.changePct === null
176 ? "n/a (previous window is zero)"
177 : `${trend.changePct >= 0 ? "+" : ""}${trend.changePct}%`;
178 lines.push(` change: ${change} (id rotation: not a retention metric)`);
179 }
180 }
181
182 if (newestEvent === null) {
183 lines.push("", "Freshness: no events ingested in the retention window");
184 } else {
185 const age = formatAge(now.getTime() - newestEvent.getTime());
186 lines.push(
187 "",
188 `Freshness: newest ingested event ${newestEvent.toISOString()} (${age} ago)`,
189 );
190 }
191
192 lines.push("", "Caveats:");
193 for (const caveat of COVERAGE_CAVEATS) {
194 lines.push(` - ${caveat}`);
195 }
196 return lines.join("\n");
197 }
198
199 async function querySql({ accountId, apiToken, sql, fetchImpl = fetch }) {
200 const response = await fetchImpl(SQL_ENDPOINT(accountId), {
201 method: "POST",
202 headers: {
203 Authorization: `Bearer ${apiToken}`,
204 "content-type": "text/plain; charset=utf-8",
205 },
206 body: sql,
207 });
208 if (!response.ok) {
209 const body = (await response.text()).slice(0, 500);
210 throw new Error(`Cloudflare SQL request failed (${response.status}): ${body}`);
211 }
212 return response.json();
213 }
214
215 export async function main(argv = process.argv.slice(2), env = process.env) {
216 const { days, json } = parseArgs(argv);
217 const accountId = env.CF_ACCOUNT_ID?.trim();
218 const apiToken = env.CF_API_TOKEN?.trim();
219 if (!accountId || !apiToken) {
220 throw new Error("CF_ACCOUNT_ID and CF_API_TOKEN are required");
221 }
222 const rows = rowsFromResponse(
223 await querySql({ accountId, apiToken, sql: activeInstallsSql(days) }),
224 );
225 const newestEvent = newestEventFromResponse(
226 await querySql({ accountId, apiToken, sql: freshnessSql() }),
227 );
228 const now = new Date();
229 if (json) {
230 process.stdout.write(
231 `${JSON.stringify(
232 {
233 metric: "observed_active_installs",
234 timezone: "UTC",
235 days,
236 rows,
237 trend: trendSummary(rows, days, now),
238 freshness: {
239 newest_event: newestEvent === null ? null : newestEvent.toISOString(),
240 age_minutes:
241 newestEvent === null
242 ? null
243 : Math.max(0, Math.floor((now.getTime() - newestEvent.getTime()) / 60_000)),
244 },
245 caveats: COVERAGE_CAVEATS,
246 },
247 null,
248 2,
249 )}\n`,
250 );
251 } else {
252 process.stdout.write(`${formatReport(rows, { days, now, newestEvent })}\n`);
253 }
254 }
255
256 export function runCli(label) {
257 main().catch((error) => {
258 process.stderr.write(`${label}: ${error.message}\n`);
259 process.exitCode = 1;
260 });
261 }
262
263 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
264 runCli("report:active-installs");
265 }
266
266 lines Plain Text