| 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 | */ |
| 19 | import type { Standing } from "./quota.ts"; |
| 20 | |
| 21 | /** How long after new bytes the month's total is reported. */ |
| 22 | export const NOTE_AFTER_MS = 5 * 60_000; |
| 23 | /** How long before the month ends the last report goes. */ |
| 24 | const MONTH_END_MARGIN_MS = 60_000; |
| 25 | /** How long before a report billing did not take is tried again. */ |
| 26 | export const NOTE_RETRY_MS = 10 * 60_000; |
| 27 | |
| 28 | const DAY_MS = 24 * 60 * 60_000; |
| 29 | |
| 30 | function day(ms = Date.now()): string { |
| 31 | return new Date(ms).toISOString().slice(0, 10); |
| 32 | } |
| 33 | function monthOf(ms = Date.now()): string { |
| 34 | return new Date(ms).toISOString().slice(0, 7); |
| 35 | } |
| 36 | /** The month before `month` (`YYYY-MM`). */ |
| 37 | function 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. */ |
| 42 | function 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. */ |
| 48 | export 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 | |
| 52 | type Row = Record<string, SqlStorageValue>; |
| 53 | |
| 54 | export 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 | } |