polar-meter-push.ts271 lines · main
| 1 | import { and, asc, eq } from 'drizzle-orm'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { projects, subscriptions, usageEvents, type UsageEvent, type UsageMetric } from '../db/schema.js'; |
| 5 | import { env } from '../env.js'; |
| 6 | import { log } from '../lib/logger.js'; |
| 7 | |
| 8 | /** |
| 9 | * Polar metering push — drains pending usage_events rows into Polar's |
| 10 | * meter API. Each metric maps to one Polar meter id: |
| 11 | * |
| 12 | * invocations → BRIVEN_POLAR_METER_INVOCATIONS_ID |
| 13 | * storage_bytes → BRIVEN_POLAR_METER_STORAGE_ID |
| 14 | * connection_seconds → BRIVEN_POLAR_METER_CONNECTION_ID |
| 15 | * auth_mau → BRIVEN_POLAR_METER_AUTH_MAU_ID |
| 16 | * auth_sso_connections → BRIVEN_POLAR_METER_AUTH_SSO_CONNECTIONS_ID |
| 17 | * auth_sso_signins → BRIVEN_POLAR_METER_AUTH_SSO_SIGNINS_ID |
| 18 | * |
| 19 | * Without the meter ids configured the worker marks rows as 'skipped' |
| 20 | * so they don't pile up forever (and a follow-up env config flip can |
| 21 | * re-enable them by resetting status='pending'). |
| 22 | * |
| 23 | * Push status transitions (see usageEvents.polarPushStatus): |
| 24 | * pending → pushed (HTTP 2xx from Polar) |
| 25 | * pending → skipped (no meter id configured for the metric) |
| 26 | * pending → pending (transient failure, retried next tick) |
| 27 | */ |
| 28 | |
| 29 | /** Batch size — keep round-trips small so a single Polar outage doesn't lock up the cron. */ |
| 30 | const BATCH_SIZE = 50; |
| 31 | /** Cron interval — once per minute. Each tick drains up to BATCH_SIZE rows. */ |
| 32 | const INTERVAL_MS = 60_000; |
| 33 | |
| 34 | /** |
| 35 | * Polar customer id required for the meter payload. Resolved through |
| 36 | * the project's org's subscription row — subscriptions are org-level |
| 37 | * (CLAUDE.md §3.3) and Polar tracks billing per customer at the same |
| 38 | * grain. Returns null when the org has no subscription yet, in which |
| 39 | * case the worker marks the row pending → skipped with reason |
| 40 | * 'no_customer' so it doesn't pile up forever; the next checkout |
| 41 | * stamps the customer id and a follow-up cron can re-enable skipped |
| 42 | * rows by resetting status='pending'. |
| 43 | * |
| 44 | * In-process cache with a 5-minute TTL — every pending row hits this |
| 45 | * during a drain and we don't want to hammer the meta-DB. Subscription |
| 46 | * upserts on the Polar webhook path invalidate the entry so a fresh |
| 47 | * customer id takes effect on the next tick, not after the TTL. |
| 48 | */ |
| 49 | interface CustomerCacheEntry { |
| 50 | customerId: string | null; |
| 51 | expiresAt: number; |
| 52 | } |
| 53 | const CUSTOMER_CACHE_TTL_MS = 5 * 60_000; |
| 54 | const customerCache = new Map<string, CustomerCacheEntry>(); |
| 55 | |
| 56 | export function invalidatePolarCustomerCache(projectId: string): void { |
| 57 | customerCache.delete(projectId); |
| 58 | } |
| 59 | |
| 60 | async function polarCustomerForProject(projectId: string): Promise<string | null> { |
| 61 | const now = Date.now(); |
| 62 | const hit = customerCache.get(projectId); |
| 63 | if (hit && hit.expiresAt > now) { |
| 64 | return hit.customerId; |
| 65 | } |
| 66 | const db = getDb(); |
| 67 | const [row] = await db |
| 68 | .select({ polarCustomerId: subscriptions.polarCustomerId }) |
| 69 | .from(projects) |
| 70 | .innerJoin(subscriptions, eq(subscriptions.orgId, projects.orgId)) |
| 71 | .where(eq(projects.id, projectId)) |
| 72 | .limit(1); |
| 73 | const customerId = row?.polarCustomerId ?? null; |
| 74 | customerCache.set(projectId, { customerId, expiresAt: now + CUSTOMER_CACHE_TTL_MS }); |
| 75 | return customerId; |
| 76 | } |
| 77 | |
| 78 | function meterIdFor(metric: UsageMetric): string | null { |
| 79 | switch (metric) { |
| 80 | case 'invocations': |
| 81 | return env.BRIVEN_POLAR_METER_INVOCATIONS_ID ?? null; |
| 82 | case 'storage_bytes': |
| 83 | return env.BRIVEN_POLAR_METER_STORAGE_ID ?? null; |
| 84 | case 'connection_seconds': |
| 85 | return env.BRIVEN_POLAR_METER_CONNECTION_ID ?? null; |
| 86 | case 'auth_mau': |
| 87 | return env.BRIVEN_POLAR_METER_AUTH_MAU_ID ?? null; |
| 88 | case 'auth_sso_connections': |
| 89 | return env.BRIVEN_POLAR_METER_AUTH_SSO_CONNECTIONS_ID ?? null; |
| 90 | case 'auth_sso_signins': |
| 91 | return env.BRIVEN_POLAR_METER_AUTH_SSO_SIGNINS_ID ?? null; |
| 92 | default: |
| 93 | return null; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Push one row. Returns the new status — caller writes it back. |
| 99 | * |
| 100 | * Without an access token + meter id + customer id the row is marked |
| 101 | * 'skipped'. With all three present we POST to Polar's Meters API. |
| 102 | * Network or 5xx → 'pending' (retried on the next tick); 4xx → 'skipped' |
| 103 | * (operator must intervene — a 400 from Polar is durable, not transient). |
| 104 | * |
| 105 | * Operator-visible logs: |
| 106 | * polar_push_pushed — 2xx response, row → 'pushed' |
| 107 | * polar_push_skipped_* — see reason field; row → 'skipped' |
| 108 | * polar_push_retry — transient failure; row stays 'pending' |
| 109 | */ |
| 110 | async function pushOne(row: UsageEvent): Promise<'pushed' | 'skipped' | 'pending'> { |
| 111 | if (!env.BRIVEN_POLAR_ACCESS_TOKEN) { |
| 112 | log.info('polar_push_skipped_no_token', { |
| 113 | eventId: row.id, |
| 114 | projectId: row.projectId, |
| 115 | metric: row.metric, |
| 116 | }); |
| 117 | return 'skipped'; |
| 118 | } |
| 119 | const meterId = meterIdFor(row.metric); |
| 120 | if (!meterId) { |
| 121 | log.info('polar_push_skipped_no_meter', { |
| 122 | eventId: row.id, |
| 123 | projectId: row.projectId, |
| 124 | metric: row.metric, |
| 125 | }); |
| 126 | return 'skipped'; |
| 127 | } |
| 128 | const customerId = await polarCustomerForProject(row.projectId); |
| 129 | if (!customerId) { |
| 130 | log.info('polar_push_skipped_no_customer', { |
| 131 | eventId: row.id, |
| 132 | projectId: row.projectId, |
| 133 | metric: row.metric, |
| 134 | }); |
| 135 | return 'skipped'; |
| 136 | } |
| 137 | |
| 138 | // Polar Meters API — POST {base}/v1/meters/{meter_id}/events |
| 139 | // { customer_id, value: number, timestamp: ISO8601 } |
| 140 | const value = Number.parseFloat(row.value); |
| 141 | if (!Number.isFinite(value)) { |
| 142 | log.warn('polar_push_skipped_bad_value', { |
| 143 | eventId: row.id, |
| 144 | projectId: row.projectId, |
| 145 | metric: row.metric, |
| 146 | raw: row.value, |
| 147 | }); |
| 148 | return 'skipped'; |
| 149 | } |
| 150 | |
| 151 | let res: Response; |
| 152 | try { |
| 153 | res = await fetch(`${env.BRIVEN_POLAR_API_BASE}/v1/meters/${meterId}/events`, { |
| 154 | method: 'POST', |
| 155 | headers: { |
| 156 | authorization: `Bearer ${env.BRIVEN_POLAR_ACCESS_TOKEN}`, |
| 157 | 'content-type': 'application/json', |
| 158 | }, |
| 159 | body: JSON.stringify({ |
| 160 | customer_id: customerId, |
| 161 | value, |
| 162 | timestamp: row.periodStart.toISOString(), |
| 163 | }), |
| 164 | }); |
| 165 | } catch (err) { |
| 166 | log.warn('polar_push_retry', { |
| 167 | eventId: row.id, |
| 168 | projectId: row.projectId, |
| 169 | metric: row.metric, |
| 170 | reason: 'network', |
| 171 | message: err instanceof Error ? err.message : String(err), |
| 172 | }); |
| 173 | return 'pending'; |
| 174 | } |
| 175 | |
| 176 | if (res.ok) { |
| 177 | log.info('polar_push_pushed', { |
| 178 | eventId: row.id, |
| 179 | projectId: row.projectId, |
| 180 | metric: row.metric, |
| 181 | meterId, |
| 182 | value, |
| 183 | }); |
| 184 | return 'pushed'; |
| 185 | } |
| 186 | |
| 187 | // 5xx is transient (Polar restarted, rate-limited, etc.) — leave the |
| 188 | // row pending and let the next tick retry. 4xx is durable (bad meter |
| 189 | // id, customer mismatch, validation) — mark skipped so we don't loop |
| 190 | // forever; an operator can re-enable after fixing the underlying issue. |
| 191 | const transient = res.status >= 500 || res.status === 429; |
| 192 | const body = await res.text().catch(() => ''); |
| 193 | log.warn(transient ? 'polar_push_retry' : 'polar_push_skipped_4xx', { |
| 194 | eventId: row.id, |
| 195 | projectId: row.projectId, |
| 196 | metric: row.metric, |
| 197 | status: res.status, |
| 198 | body: body.slice(0, 256), |
| 199 | }); |
| 200 | return transient ? 'pending' : 'skipped'; |
| 201 | } |
| 202 | |
| 203 | export async function drainPendingPolarPushes(): Promise<{ |
| 204 | scanned: number; |
| 205 | pushed: number; |
| 206 | skipped: number; |
| 207 | }> { |
| 208 | const db = getDb(); |
| 209 | const rows = await db |
| 210 | .select() |
| 211 | .from(usageEvents) |
| 212 | .where(eq(usageEvents.polarPushStatus, 'pending')) |
| 213 | .orderBy(asc(usageEvents.periodStart)) |
| 214 | .limit(BATCH_SIZE); |
| 215 | |
| 216 | let pushed = 0; |
| 217 | let skipped = 0; |
| 218 | for (const row of rows) { |
| 219 | try { |
| 220 | const next = await pushOne(row); |
| 221 | if (next === 'pushed') { |
| 222 | await db |
| 223 | .update(usageEvents) |
| 224 | .set({ polarPushStatus: 'pushed', polarPushedAt: new Date() }) |
| 225 | .where(and(eq(usageEvents.id, row.id))); |
| 226 | pushed += 1; |
| 227 | } else if (next === 'skipped') { |
| 228 | await db |
| 229 | .update(usageEvents) |
| 230 | .set({ polarPushStatus: 'skipped' }) |
| 231 | .where(and(eq(usageEvents.id, row.id))); |
| 232 | skipped += 1; |
| 233 | } |
| 234 | // 'pending' = retry next tick — don't touch the row. |
| 235 | } catch (err) { |
| 236 | log.warn('polar_push_row_failed', { |
| 237 | eventId: row.id, |
| 238 | message: err instanceof Error ? err.message : String(err), |
| 239 | }); |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | if (rows.length > 0) { |
| 244 | log.info('polar_push_drain', { |
| 245 | scanned: rows.length, |
| 246 | pushed, |
| 247 | skipped, |
| 248 | pending: rows.length - pushed - skipped, |
| 249 | }); |
| 250 | } |
| 251 | return { scanned: rows.length, pushed, skipped }; |
| 252 | } |
| 253 | |
| 254 | let timer: ReturnType<typeof setInterval> | null = null; |
| 255 | |
| 256 | export function startPolarMeterPush(): void { |
| 257 | if (timer) return; |
| 258 | if (!env.BRIVEN_DATABASE_URL) { |
| 259 | log.warn('polar_meter_push_skipped_no_db'); |
| 260 | return; |
| 261 | } |
| 262 | // Initial run 30s after boot so migrations finish + the first |
| 263 | // aggregator cycle isn't competing for the same rows. |
| 264 | setTimeout(() => { |
| 265 | void drainPendingPolarPushes(); |
| 266 | timer = setInterval(() => { |
| 267 | void drainPendingPolarPushes(); |
| 268 | }, INTERVAL_MS); |
| 269 | }, 30_000).unref?.(); |
| 270 | log.info('polar_meter_push_armed', { intervalMs: INTERVAL_MS }); |
| 271 | } |