sla.ts241 lines · main
| 1 | import { and, eq, gte, sql } from 'drizzle-orm'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { auditLogs, projects } from '../db/schema.js'; |
| 5 | import { log } from '../lib/logger.js'; |
| 6 | import { audit } from './audit.js'; |
| 7 | |
| 8 | /** |
| 9 | * SLA enforcement — Phase 4 launch-readiness. |
| 10 | * |
| 11 | * briven surfaces per-tier SLA targets on the billing page (Pro 99.5%, |
| 12 | * Team 99.9%). For year-one + private-beta these are operational |
| 13 | * commitments — uptime tracked, customers informed — but no auto-credit |
| 14 | * fires when a breach happens. This service is the auto-credit half. |
| 15 | * |
| 16 | * Architecture: |
| 17 | * 1. Uptime is measured per service per minute (alertmanager source of |
| 18 | * truth). For now we infer breaches from a simple model: count the |
| 19 | * number of seconds the api was unreachable in a billing period. |
| 20 | * 2. Each billing period (calendar month UTC) we compute uptime% = |
| 21 | * 1 - (downtime_seconds / period_seconds). |
| 22 | * 3. If uptime% < tier.slaTargetUptime, we issue an auto-credit equal |
| 23 | * to a percentage of the monthly subscription fee (per the |
| 24 | * breach severity matrix below). |
| 25 | * 4. The credit lands as a one-time discount on the next Polar invoice; |
| 26 | * we POST to Polar's billing-credit endpoint when it lands. Until |
| 27 | * then we just write an audit_log row marking the intent. |
| 28 | * |
| 29 | * Today this service exposes the math + the audit-log writer. The |
| 30 | * downtime-seconds source (alertmanager → durable file) is the next |
| 31 | * piece to wire; until then `recordedDowntimeSeconds()` returns 0 and |
| 32 | * no breaches fire. |
| 33 | */ |
| 34 | |
| 35 | export interface SlaTier { |
| 36 | /** 0-1. e.g. 0.995 means 99.5%. */ |
| 37 | readonly targetUptime: number; |
| 38 | /** Max downtime allowed in a calendar month (seconds). Derived from |
| 39 | * targetUptime × month length. */ |
| 40 | readonly maxMonthlyDowntimeSeconds: number; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Per-tier SLA targets. Numbers match the dashboard /billing surface; |
| 45 | * free tier is best-effort (no SLA target, no credit ever fires). |
| 46 | */ |
| 47 | export const SLA_TIERS: Record<'free' | 'pro' | 'team', SlaTier | null> = { |
| 48 | free: null, |
| 49 | pro: { |
| 50 | targetUptime: 0.995, |
| 51 | // 30 days × 86400 s = 2,592,000 s. 0.5% downtime = 12,960 s ≈ 3h 36m. |
| 52 | maxMonthlyDowntimeSeconds: Math.floor((1 - 0.995) * 30 * 86400), |
| 53 | }, |
| 54 | team: { |
| 55 | targetUptime: 0.999, |
| 56 | // 30 days × 86400 s = 2,592,000 s. 0.1% downtime = 2,592 s ≈ 43m. |
| 57 | maxMonthlyDowntimeSeconds: Math.floor((1 - 0.999) * 30 * 86400), |
| 58 | }, |
| 59 | }; |
| 60 | |
| 61 | /** |
| 62 | * Severity-graded credit. Worse breaches return more. |
| 63 | * |
| 64 | * target uptime − actual uptime ratio → |
| 65 | * ≤ 0.5% over the breach point: 10% credit |
| 66 | * 0.5% – 1%: 25% credit |
| 67 | * 1% – 5%: 50% credit |
| 68 | * > 5%: 100% credit (full month free) |
| 69 | * |
| 70 | * Numbers anchor to the Convex / Vercel / Render published SLAs. |
| 71 | * Confirm with billing-side legal before publishing publicly. |
| 72 | */ |
| 73 | export function creditPercentForBreach(target: number, actual: number): number { |
| 74 | if (actual >= target) return 0; |
| 75 | const delta = target - actual; |
| 76 | if (delta <= 0.005) return 0.1; |
| 77 | if (delta <= 0.01) return 0.25; |
| 78 | if (delta <= 0.05) return 0.5; |
| 79 | return 1.0; |
| 80 | } |
| 81 | |
| 82 | export interface UptimeReport { |
| 83 | readonly periodStart: Date; |
| 84 | readonly periodEnd: Date; |
| 85 | readonly downtimeSeconds: number; |
| 86 | readonly uptimeRatio: number; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Compute uptime for a given period from the durable downtime source. |
| 91 | * Today the source is a stub returning 0 — wire alertmanager's |
| 92 | * persistent `incidents.ts` file as the source of truth once the |
| 93 | * writer ships (see docs/runbooks/discord-setup.md §4). |
| 94 | */ |
| 95 | export async function uptimeForPeriod( |
| 96 | periodStart: Date, |
| 97 | periodEnd: Date, |
| 98 | ): Promise<UptimeReport> { |
| 99 | const downtimeSeconds = await recordedDowntimeSeconds(periodStart, periodEnd); |
| 100 | const totalSeconds = Math.max(1, Math.floor((periodEnd.getTime() - periodStart.getTime()) / 1000)); |
| 101 | return { |
| 102 | periodStart, |
| 103 | periodEnd, |
| 104 | downtimeSeconds, |
| 105 | uptimeRatio: 1 - downtimeSeconds / totalSeconds, |
| 106 | }; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Stub today. When the alertmanager → incidents.ts writer ships, this |
| 111 | * reads from the persistent file: sum `(resolvedAt - startedAt)` over |
| 112 | * every incident with severity in ('critical', 'major') in the period. |
| 113 | */ |
| 114 | async function recordedDowntimeSeconds( |
| 115 | _periodStart: Date, |
| 116 | _periodEnd: Date, |
| 117 | ): Promise<number> { |
| 118 | // Intentional 0 until the writer is in place. Uptime always reports |
| 119 | // 100% — no false breach fires before we have a real signal. |
| 120 | return 0; |
| 121 | } |
| 122 | |
| 123 | export interface BreachOutcome { |
| 124 | readonly projectId: string; |
| 125 | readonly tier: 'pro' | 'team'; |
| 126 | readonly periodStart: string; |
| 127 | readonly periodEnd: string; |
| 128 | readonly uptimeRatio: number; |
| 129 | readonly targetUptime: number; |
| 130 | readonly creditPercent: number; |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * For each project on a paid tier, check the previous calendar month's |
| 135 | * uptime. If it fell below the tier target, write an audit row marking |
| 136 | * the breach + the credit %, and return the entries the operator can |
| 137 | * push to Polar as one-time discounts. |
| 138 | * |
| 139 | * Idempotent via the audit log — re-running for an already-evaluated |
| 140 | * period is a no-op (the row already exists). |
| 141 | */ |
| 142 | export async function evaluateBreachesForPeriod( |
| 143 | periodStart: Date, |
| 144 | periodEnd: Date, |
| 145 | ): Promise<readonly BreachOutcome[]> { |
| 146 | const report = await uptimeForPeriod(periodStart, periodEnd); |
| 147 | // Fast path: if uptime is at the highest target, no project breached. |
| 148 | if (report.uptimeRatio >= 0.999) return []; |
| 149 | |
| 150 | const db = getDb(); |
| 151 | const paidProjects = (await db |
| 152 | .select({ id: projects.id, tier: projects.tier }) |
| 153 | .from(projects) |
| 154 | .where(sql`${projects.tier} <> 'free' AND ${projects.deletedAt} IS NULL`)) as { |
| 155 | id: string; |
| 156 | tier: 'pro' | 'team'; |
| 157 | }[]; |
| 158 | |
| 159 | const outcomes: BreachOutcome[] = []; |
| 160 | for (const p of paidProjects) { |
| 161 | const sla = SLA_TIERS[p.tier]; |
| 162 | if (!sla) continue; |
| 163 | const creditPercent = creditPercentForBreach(sla.targetUptime, report.uptimeRatio); |
| 164 | if (creditPercent === 0) continue; |
| 165 | |
| 166 | // Audit-log the breach. Idempotency: the (action, project, period) |
| 167 | // triple is unique per breach decision; a second call for the same |
| 168 | // period inserts a duplicate row only if the data changed. |
| 169 | const existed = await db |
| 170 | .select({ id: auditLogs.id }) |
| 171 | .from(auditLogs) |
| 172 | .where( |
| 173 | and( |
| 174 | eq(auditLogs.action, 'sla.breach.evaluated'), |
| 175 | eq(auditLogs.projectId, p.id), |
| 176 | gte(auditLogs.createdAt, periodStart), |
| 177 | ), |
| 178 | ) |
| 179 | .limit(1); |
| 180 | if (existed.length > 0) continue; |
| 181 | |
| 182 | await audit({ |
| 183 | action: 'sla.breach.evaluated', |
| 184 | actorId: null, |
| 185 | projectId: p.id, |
| 186 | ipHash: null, |
| 187 | userAgent: null, |
| 188 | metadata: { |
| 189 | tier: p.tier, |
| 190 | periodStart: periodStart.toISOString(), |
| 191 | periodEnd: periodEnd.toISOString(), |
| 192 | downtimeSeconds: report.downtimeSeconds, |
| 193 | uptimeRatio: report.uptimeRatio, |
| 194 | targetUptime: sla.targetUptime, |
| 195 | creditPercent, |
| 196 | }, |
| 197 | }); |
| 198 | |
| 199 | outcomes.push({ |
| 200 | projectId: p.id, |
| 201 | tier: p.tier, |
| 202 | periodStart: periodStart.toISOString(), |
| 203 | periodEnd: periodEnd.toISOString(), |
| 204 | uptimeRatio: report.uptimeRatio, |
| 205 | targetUptime: sla.targetUptime, |
| 206 | creditPercent, |
| 207 | }); |
| 208 | } |
| 209 | |
| 210 | if (outcomes.length > 0) { |
| 211 | log.warn('sla_breach_evaluated', { |
| 212 | period: periodStart.toISOString(), |
| 213 | projectsCredited: outcomes.length, |
| 214 | uptimeRatio: report.uptimeRatio, |
| 215 | }); |
| 216 | } |
| 217 | return outcomes; |
| 218 | } |
| 219 | |
| 220 | /** |
| 221 | * Operator entrypoint — sum the credit value across all breaches for |
| 222 | * the previous calendar month. Returns the entries to push to Polar |
| 223 | * manually OR (when the polar-credit-push worker ships) to drain |
| 224 | * automatically. For now this is a read-only summary; operator runs |
| 225 | * it from the admin dashboard and reaches out to affected customers. |
| 226 | */ |
| 227 | export function previousMonthBounds(now: Date = new Date()): { start: Date; end: Date } { |
| 228 | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)); |
| 229 | const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0)); |
| 230 | return { start, end }; |
| 231 | } |
| 232 | |
| 233 | export function tierMonthlyFee(tier: 'pro' | 'team'): number { |
| 234 | // Placeholder figures — actual fees live on the Polar product |
| 235 | // configuration. This is here only to size the credit estimate the |
| 236 | // operator sees in the admin UI. When the polar-credit-push worker |
| 237 | // ships, swap this for a `fetchPolarSubscriptionAmount(orgId)` call. |
| 238 | if (tier === 'pro') return 20; |
| 239 | if (tier === 'team') return 200; |
| 240 | return 0; |
| 241 | } |