usage.ts220 lines · main
| 1 | import { and, eq, gte, lt, sql } from 'drizzle-orm'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { dbNameFor, runInProjectDatabase } from '../db/data-plane.js'; |
| 5 | import { functionLogs, usageEvents } from '../db/schema.js'; |
| 6 | import { log } from '../lib/logger.js'; |
| 7 | |
| 8 | /** |
| 9 | * Phase 3 usage metering — invocations slice. |
| 10 | * |
| 11 | * Reads the meta-DB's `function_logs` table (where `apps/api/src/workers/ |
| 12 | * log-fanout.ts` durably persists every invocation envelope from the |
| 13 | * runtime). Aggregates `count` and `totalDurationMs` for billing / |
| 14 | * dashboard surfaces. |
| 15 | * |
| 16 | * Caveat: `function_logs` is pruned to 7 days on free tier (see |
| 17 | * `pruneOldFunctionLogs` in `workers/log-fanout.ts`). Querying outside |
| 18 | * the retention window returns under-counted data. Resolving this needs |
| 19 | * either tier-aware retention extension, or a periodic `usage_rollups` |
| 20 | * snapshot landed before pruning runs — both deferred follow-ups. |
| 21 | */ |
| 22 | |
| 23 | export interface InvocationUsage { |
| 24 | /** Total successful + errored invocations in the window. */ |
| 25 | readonly count: number; |
| 26 | /** |
| 27 | * Sum of `durationMs` across all invocations in the window. Stored as |
| 28 | * varchar in `function_logs`; cast to int in SQL. Caps at 2^31-1 ms |
| 29 | * (~24 days of cumulative compute) — beyond that, swap to bigint. |
| 30 | */ |
| 31 | readonly totalDurationMs: number; |
| 32 | /** Echoed back so callers can verify the period they asked for. */ |
| 33 | readonly periodStart: string; |
| 34 | readonly periodEnd: string; |
| 35 | } |
| 36 | |
| 37 | export async function getInvocationUsage( |
| 38 | projectId: string, |
| 39 | periodStart: Date, |
| 40 | periodEnd: Date, |
| 41 | ): Promise<InvocationUsage> { |
| 42 | if (periodEnd <= periodStart) { |
| 43 | throw new Error('periodEnd must be after periodStart'); |
| 44 | } |
| 45 | const db = getDb(); |
| 46 | const [row] = await db |
| 47 | .select({ |
| 48 | count: sql<number>`count(*)::int`, |
| 49 | totalDurationMs: sql<number>`coalesce(sum(${functionLogs.durationMs}::int), 0)::int`, |
| 50 | }) |
| 51 | .from(functionLogs) |
| 52 | .where( |
| 53 | and( |
| 54 | eq(functionLogs.projectId, projectId), |
| 55 | gte(functionLogs.createdAt, periodStart), |
| 56 | lt(functionLogs.createdAt, periodEnd), |
| 57 | ), |
| 58 | ); |
| 59 | return { |
| 60 | count: row?.count ?? 0, |
| 61 | totalDurationMs: row?.totalDurationMs ?? 0, |
| 62 | periodStart: periodStart.toISOString(), |
| 63 | periodEnd: periodEnd.toISOString(), |
| 64 | }; |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * UTC calendar-month boundaries for a given reference date. Used by the |
| 69 | * dashboard "this month so far" widget — invocation counts compare to |
| 70 | * `TIERS[tier].invokesPerMonth`. The boundary is the first millisecond |
| 71 | * of the next month, not the last of the current — `[start, end)` |
| 72 | * matches the SQL `gte` + `lt` aggregation contract above. |
| 73 | */ |
| 74 | export function currentMonthBounds(now: Date = new Date()): { |
| 75 | periodStart: Date; |
| 76 | periodEnd: Date; |
| 77 | } { |
| 78 | const periodStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)); |
| 79 | const periodEnd = new Date( |
| 80 | Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0), |
| 81 | ); |
| 82 | return { periodStart, periodEnd }; |
| 83 | } |
| 84 | |
| 85 | export async function getCurrentMonthInvocationUsage( |
| 86 | projectId: string, |
| 87 | now: Date = new Date(), |
| 88 | ): Promise<InvocationUsage> { |
| 89 | const { periodStart, periodEnd } = currentMonthBounds(now); |
| 90 | return getInvocationUsage(projectId, periodStart, periodEnd); |
| 91 | } |
| 92 | |
| 93 | export interface StorageUsage { |
| 94 | /** |
| 95 | * Total bytes on disk for every relation in the project's schema — |
| 96 | * sums table data + indexes + toast via pg_total_relation_size. |
| 97 | * Excludes the platform's _briven_* tables so the customer's usage |
| 98 | * isn't inflated by our bookkeeping. |
| 99 | */ |
| 100 | readonly bytes: number; |
| 101 | /** Number of user tables (excludes _briven_*). */ |
| 102 | readonly tableCount: number; |
| 103 | /** Echoed back so callers know which schema was measured. */ |
| 104 | readonly schema: string; |
| 105 | /** Timestamp at which the size was sampled. */ |
| 106 | readonly sampledAt: string; |
| 107 | } |
| 108 | |
| 109 | export interface ConnectionSecondsUsage { |
| 110 | /** |
| 111 | * Sum of realtime connection-seconds across the period. Source is |
| 112 | * `usage_events` rows (metric='connection_seconds') populated hourly |
| 113 | * by the aggregator's /metrics scrape — durable across api/realtime |
| 114 | * restarts, unlike the in-memory `briven_realtime_connection_seconds_total` |
| 115 | * gauge. |
| 116 | */ |
| 117 | readonly seconds: number; |
| 118 | readonly periodStart: string; |
| 119 | readonly periodEnd: string; |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Sum connection_seconds rows in [periodStart, periodEnd) for one project. |
| 124 | * Returns 0 when the aggregator hasn't yet written a row — the dashboard |
| 125 | * renders that as "—" naturally. |
| 126 | */ |
| 127 | export async function getConnectionSecondsUsage( |
| 128 | projectId: string, |
| 129 | periodStart: Date, |
| 130 | periodEnd: Date, |
| 131 | ): Promise<ConnectionSecondsUsage> { |
| 132 | if (periodEnd <= periodStart) { |
| 133 | throw new Error('periodEnd must be after periodStart'); |
| 134 | } |
| 135 | const db = getDb(); |
| 136 | const [row] = await db |
| 137 | .select({ |
| 138 | seconds: sql<number>`coalesce(sum(${usageEvents.value}::bigint), 0)::bigint`, |
| 139 | }) |
| 140 | .from(usageEvents) |
| 141 | .where( |
| 142 | and( |
| 143 | eq(usageEvents.projectId, projectId), |
| 144 | eq(usageEvents.metric, 'connection_seconds'), |
| 145 | gte(usageEvents.periodStart, periodStart), |
| 146 | lt(usageEvents.periodStart, periodEnd), |
| 147 | ), |
| 148 | ); |
| 149 | // drizzle returns bigint as string in some setups — coerce to number. |
| 150 | const raw = row?.seconds; |
| 151 | const seconds = typeof raw === 'string' ? Number.parseInt(raw, 10) : (raw ?? 0); |
| 152 | return { |
| 153 | seconds: Number.isFinite(seconds) ? seconds : 0, |
| 154 | periodStart: periodStart.toISOString(), |
| 155 | periodEnd: periodEnd.toISOString(), |
| 156 | }; |
| 157 | } |
| 158 | |
| 159 | export async function getCurrentMonthConnectionSecondsUsage( |
| 160 | projectId: string, |
| 161 | now: Date = new Date(), |
| 162 | ): Promise<ConnectionSecondsUsage> { |
| 163 | const { periodStart, periodEnd } = currentMonthBounds(now); |
| 164 | return getConnectionSecondsUsage(projectId, periodStart, periodEnd); |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Query the data-plane for the storage footprint of a project's schema. |
| 169 | * Cheap — single round-trip with a pg_total_relation_size aggregate. |
| 170 | * Used by the dashboard usage widget and (Phase 3 follow-up) by the |
| 171 | * Polar metering push. |
| 172 | * |
| 173 | * Returns {bytes:0, tableCount:0} when the schema doesn't exist yet |
| 174 | * (project was just created but no deploy has landed) instead of |
| 175 | * throwing — the dashboard renders this as "—" naturally. |
| 176 | */ |
| 177 | export async function getStorageUsage(projectId: string): Promise<StorageUsage> { |
| 178 | // db-per-project (sprint S2.2): the previous code used postgres.js + a |
| 179 | // schema-per-project name, which queried the WRONG database and silently |
| 180 | // returned 0. Run against the project's OWN DoltGres database (pg, bound to |
| 181 | // proj_<id>); its tables live in `public`. `left(...)` instead of LIKE |
| 182 | // because DoltGres lacks the bare LIKE escape the old filter relied on. |
| 183 | // |
| 184 | // KNOWN LIMITATION: DoltGres reports pg_total_relation_size/pg_relation_size |
| 185 | // as 0 (no on-disk size accounting yet) and has no information_schema.tables |
| 186 | // data_length. So `bytes` is best-effort and will read 0 until DoltGres adds |
| 187 | // size reporting. `tableCount` IS accurate. Storage-byte billing must not |
| 188 | // depend on `bytes` yet (track via row counts / a future size source). The |
| 189 | // dolt-compat alarm probes this so we know when it changes. |
| 190 | const schema = dbNameFor(projectId); |
| 191 | const sampledAt = new Date().toISOString(); |
| 192 | try { |
| 193 | const rows = (await runInProjectDatabase(projectId, async (tx) => |
| 194 | tx.unsafe(` |
| 195 | SELECT |
| 196 | COALESCE(SUM(pg_total_relation_size(format('%I.%I', n.nspname, c.relname)::regclass)), 0)::bigint AS bytes, |
| 197 | COUNT(*)::bigint AS table_count |
| 198 | FROM pg_class c |
| 199 | JOIN pg_namespace n ON n.oid = c.relnamespace |
| 200 | WHERE n.nspname = 'public' |
| 201 | AND c.relkind IN ('r', 'p') -- ordinary + partitioned tables |
| 202 | AND left(c.relname, 8) <> '_briven_' |
| 203 | `), |
| 204 | )) as Array<{ bytes: string; table_count: string }>; |
| 205 | const row = rows[0]; |
| 206 | return { |
| 207 | bytes: row ? Number.parseInt(row.bytes, 10) : 0, |
| 208 | tableCount: row ? Number.parseInt(row.table_count, 10) : 0, |
| 209 | schema, |
| 210 | sampledAt, |
| 211 | }; |
| 212 | } catch (err) { |
| 213 | log.warn('storage_usage_query_failed', { |
| 214 | projectId, |
| 215 | schema, |
| 216 | message: err instanceof Error ? err.message : String(err), |
| 217 | }); |
| 218 | return { bytes: 0, tableCount: 0, schema, sampledAt }; |
| 219 | } |
| 220 | } |