sla.ts241 lines · main
1import { and, eq, gte, sql } from 'drizzle-orm';
2
3import { getDb } from '../db/client.js';
4import { auditLogs, projects } from '../db/schema.js';
5import { log } from '../lib/logger.js';
6import { 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
35export 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 */
47export 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 */
73export 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
82export 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 */
95export 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 */
114async 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
123export 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 */
142export 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 */
227export 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
233export 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}