Skip to content
167 linesCodeBlameRaw
1/**
2 * One workspace's observability, counted across all its projects: what
3 * arrived each day (for a free workspace's daily share, ./quota.ts) and
4 * each month (for what the plan is charged past the free amount). One
5 * Durable Object per workspace (`TelemetryMeter`, ./durable.ts), named by
6 * its slug; this is its logic, apart from the object, so tests run it on
7 * Node's SQLite.
8 *
9 * It also remembers the plan billing last said the workspace is on, so a
10 * billing that does not answer keeps the cap where it was.
11 *
12 * A workspace on the plan has its month's total reported to billing
13 * (`note_pending`, source `telemetry`) from the object's alarm, a few
14 * minutes after it changes, and again until billing takes it. Each report
15 * names the month its total belongs to, so one that arrives after the month
16 * is over still counts for it (billing takes the month before while it is
17 * not charged); the alarm still goes off before the month ends.
18 */
19import type { Standing } from "./quota.ts";
20
21/** How long after new bytes the month's total is reported. */
22export const NOTE_AFTER_MS = 5 * 60_000;
23/** How long before the month ends the last report goes. */
24const MONTH_END_MARGIN_MS = 60_000;
25/** How long before a report billing did not take is tried again. */
26export const NOTE_RETRY_MS = 10 * 60_000;
27
28const DAY_MS = 24 * 60 * 60_000;
29
30function day(ms = Date.now()): string {
31 return new Date(ms).toISOString().slice(0, 10);
32}
33function monthOf(ms = Date.now()): string {
34 return new Date(ms).toISOString().slice(0, 7);
35}
36/** The month before `month` (`YYYY-MM`). */
37function previousMonth(month: string): string {
38 const [year, number] = month.split("-").map(Number);
39 return new Date(Date.UTC(year, number - 2, 1)).toISOString().slice(0, 7);
40}
41/** The first instant of the month after the one `ms` is in. */
42function nextMonth(ms: number): number {
43 const at = new Date(ms);
44 return Date.UTC(at.getUTCFullYear(), at.getUTCMonth() + 1, 1);
45}
46
47/** When to report next: soon, but before the month is over. */
48export function noteAt(now: number, after = NOTE_AFTER_MS): number {
49 return Math.max(now + 1000, Math.min(now + after, nextMonth(now) - MONTH_END_MARGIN_MS));
50}
51
52type Row = Record<string, SqlStorageValue>;
53
54export class WorkspaceMeter {
55 state!: DurableObjectState;
56 sql!: SqlStorage;
57
58 constructor(state: DurableObjectState) {
59 this.open(state);
60 }
61
62 open(state: DurableObjectState) {
63 this.state = state;
64 this.sql = state.storage.sql;
65 this.sql.exec(`
66 CREATE TABLE IF NOT EXISTS meter_days (day TEXT PRIMARY KEY, bytes INTEGER NOT NULL);
67 CREATE TABLE IF NOT EXISTS meter_months (month TEXT PRIMARY KEY, bytes INTEGER NOT NULL, noted_bytes INTEGER NOT NULL DEFAULT 0);
68 CREATE TABLE IF NOT EXISTS meter_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
69 `);
70 }
71
72 rows(query: string, ...args: SqlStorageValue[]): Row[] {
73 return this.sql.exec(query, ...args).toArray() as Row[];
74 }
75
76 private meta(key: string): string | null {
77 const row = this.rows("SELECT value FROM meter_meta WHERE key = ?", key)[0];
78 return row ? String(row.value) : null;
79 }
80
81 private setMeta(key: string, value: string) {
82 this.sql.exec("INSERT INTO meter_meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", key, value);
83 }
84
85 /** The workspace's slug, as it was last counted under. */
86 workspace(): string | null {
87 return this.meta("workspace");
88 }
89
90 /** The plan billing last said the workspace is on; null if it never has. */
91 standing(): Standing | null {
92 const value = this.meta("standing");
93 return value === "free" || value === "paid" || value === "held" ? value : null;
94 }
95
96 setStanding(standing: Standing): void {
97 if (standing !== this.standing()) this.setMeta("standing", standing);
98 }
99
100 /** Bytes all the workspace's projects sent today (UTC). */
101 todayBytes(now = Date.now()): number {
102 return Number(this.rows("SELECT bytes FROM meter_days WHERE day = ?", day(now))[0]?.bytes ?? 0);
103 }
104
105 /** Bytes all the workspace's projects sent in `month` (`YYYY-MM`), this one by default. */
106 monthBytes(month = monthOf()): number {
107 return Number(this.rows("SELECT bytes FROM meter_months WHERE month = ?", month)[0]?.bytes ?? 0);
108 }
109
110 /** Whether `adding` more today would go past `cap` (null: no cap). */
111 over(adding: number, cap: number | null, now = Date.now()): boolean {
112 return cap != null && cap >= 0 && this.todayBytes(now) + adding > cap;
113 }
114
115 /**
116 * Counts what the workspace's projects kept. On the plan, the month's
117 * total is reported to billing shortly (`note`, from the alarm).
118 */
119 async add(workspace: string, bytes: number, now = Date.now()): Promise<void> {
120 if (workspace && workspace !== this.workspace()) this.setMeta("workspace", workspace);
121 const n = Math.max(0, Math.round(bytes));
122 if (!n) return;
123 this.sql.exec(
124 "INSERT INTO meter_days (day, bytes) VALUES (?, ?) ON CONFLICT (day) DO UPDATE SET bytes = bytes + excluded.bytes",
125 day(now), n,
126 );
127 this.sql.exec(
128 "INSERT INTO meter_months (month, bytes) VALUES (?, ?) ON CONFLICT (month) DO UPDATE SET bytes = bytes + excluded.bytes",
129 monthOf(now), n,
130 );
131 this.sql.exec("DELETE FROM meter_days WHERE day < ?", day(now - 40 * DAY_MS));
132 if (this.standing() === "paid" && (await this.state.storage.getAlarm()) === null) {
133 await this.state.storage.setAlarm(noteAt(now));
134 }
135 }
136
137 /**
138 * Reports the month's total to billing with `send`, naming the month,
139 * when billing has not heard it, on the plan; tried again later when
140 * `send` fails. The month before is reported too while billing has not
141 * heard all of it (billing takes it until it charges that month, and
142 * answers whether it did); older months are left as last heard.
143 */
144 async note(send: (workspace: string, month: string, bytes: number) => Promise<boolean>, now = Date.now()): Promise<void> {
145 const workspace = this.workspace();
146 const current = monthOf(now);
147 const previous = previousMonth(current);
148 for (const row of this.rows("SELECT month, bytes FROM meter_months WHERE bytes <> noted_bytes AND month < ?", previous)) {
149 console.error("telemetry: a month that is over was not reported in full", workspace, row.month, row.bytes);
150 this.sql.exec("UPDATE meter_months SET noted_bytes = bytes WHERE month = ?", row.month);
151 }
152 if (!workspace || this.standing() !== "paid") return;
153 let retry = false;
154 for (const month of [previous, current]) {
155 const row = this.rows("SELECT bytes, noted_bytes FROM meter_months WHERE month = ?", month)[0];
156 const bytes = Number(row?.bytes ?? 0);
157 if (bytes === Number(row?.noted_bytes ?? 0)) continue;
158 const ok = await send(workspace, month, bytes).catch((error: unknown) => {
159 console.error("telemetry: usage not reported", workspace, month, String(error));
160 return false;
161 });
162 if (ok) this.sql.exec("UPDATE meter_months SET noted_bytes = ? WHERE month = ?", bytes, month);
163 else retry = true;
164 }
165 if (retry) await this.state.storage.setAlarm(noteAt(now, NOTE_RETRY_MS));
166 }
167}