@littlebearapps/platform-admin-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +89 -0
- package/dist/prompts.d.ts +27 -0
- package/dist/prompts.js +80 -0
- package/dist/scaffold.d.ts +5 -0
- package/dist/scaffold.js +65 -0
- package/dist/templates.d.ts +16 -0
- package/dist/templates.js +131 -0
- package/package.json +46 -0
- package/templates/full/migrations/006_pattern_discovery.sql +199 -0
- package/templates/full/migrations/007_notifications_search.sql +127 -0
- package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
- package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
- package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
- package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
- package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
- package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
- package/templates/full/workers/pattern-discovery.ts +661 -0
- package/templates/full/workers/platform-alert-router.ts +1809 -0
- package/templates/full/workers/platform-notifications.ts +424 -0
- package/templates/full/workers/platform-search.ts +480 -0
- package/templates/full/workers/platform-settings.ts +436 -0
- package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
- package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
- package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
- package/templates/full/wrangler.search.jsonc.hbs +16 -0
- package/templates/full/wrangler.settings.jsonc.hbs +23 -0
- package/templates/shared/README.md.hbs +69 -0
- package/templates/shared/config/budgets.yaml.hbs +72 -0
- package/templates/shared/config/services.yaml.hbs +45 -0
- package/templates/shared/migrations/001_core_tables.sql +117 -0
- package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
- package/templates/shared/migrations/003_feature_tracking.sql +250 -0
- package/templates/shared/migrations/004_settings_alerts.sql +452 -0
- package/templates/shared/migrations/seed.sql.hbs +4 -0
- package/templates/shared/package.json.hbs +21 -0
- package/templates/shared/scripts/sync-config.ts +242 -0
- package/templates/shared/tsconfig.json +12 -0
- package/templates/shared/workers/lib/analytics-engine.ts +357 -0
- package/templates/shared/workers/lib/billing.ts +293 -0
- package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
- package/templates/shared/workers/lib/control.ts +292 -0
- package/templates/shared/workers/lib/economics.ts +368 -0
- package/templates/shared/workers/lib/metrics.ts +103 -0
- package/templates/shared/workers/lib/platform-settings.ts +407 -0
- package/templates/shared/workers/lib/shared/allowances.ts +333 -0
- package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
- package/templates/shared/workers/lib/shared/types.ts +58 -0
- package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
- package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
- package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
- package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
- package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
- package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
- package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
- package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
- package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
- package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
- package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
- package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
- package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
- package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
- package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
- package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
- package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
- package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
- package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
- package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
- package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
- package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
- package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
- package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
- package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
- package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
- package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
- package/templates/shared/workers/platform-usage.ts +1915 -0
- package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
- package/templates/standard/migrations/005_error_collection.sql +162 -0
- package/templates/standard/workers/error-collector.ts +2670 -0
- package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
- package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
- package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
- package/templates/standard/workers/lib/error-collector/github.ts +329 -0
- package/templates/standard/workers/lib/error-collector/types.ts +262 -0
- package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
- package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
- package/templates/standard/workers/platform-sentinel.ts +1744 -0
- package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
- package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing Period Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides billing-cycle-aware calculations for accurate allowance proration.
|
|
5
|
+
* Supports both calendar-month and mid-month billing cycles.
|
|
6
|
+
*
|
|
7
|
+
* @see https://developers.cloudflare.com/workers/platform/pricing/
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Billing period information
|
|
12
|
+
*/
|
|
13
|
+
export interface BillingPeriod {
|
|
14
|
+
/** Start date of the current billing period */
|
|
15
|
+
startDate: Date;
|
|
16
|
+
/** End date of the current billing period */
|
|
17
|
+
endDate: Date;
|
|
18
|
+
/** Total days in this billing period */
|
|
19
|
+
daysInPeriod: number;
|
|
20
|
+
/** Days elapsed since billing period started */
|
|
21
|
+
daysElapsed: number;
|
|
22
|
+
/** Days remaining until billing period ends */
|
|
23
|
+
daysRemaining: number;
|
|
24
|
+
/** Progress through billing period (0-1) */
|
|
25
|
+
progress: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plan types supported by Cloudflare
|
|
30
|
+
*/
|
|
31
|
+
export type PlanType = 'free' | 'paid' | 'enterprise';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Billing settings from D1
|
|
35
|
+
*/
|
|
36
|
+
export interface BillingSettings {
|
|
37
|
+
accountId: string;
|
|
38
|
+
planType: PlanType;
|
|
39
|
+
billingCycleDay: number; // 1-28 or 0 for calendar month
|
|
40
|
+
billingCurrency: string;
|
|
41
|
+
baseCostMonthly: number;
|
|
42
|
+
notes?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Calculate the billing period boundaries for a given reference date.
|
|
47
|
+
*
|
|
48
|
+
* @param billingCycleDay - Day of month billing starts (1-28) or 0 for calendar month
|
|
49
|
+
* @param refDate - Reference date (defaults to now)
|
|
50
|
+
* @returns Billing period information
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* // Calendar month billing (billing_cycle_day = 0 or 1)
|
|
54
|
+
* calculateBillingPeriod(1, new Date('2026-01-15'))
|
|
55
|
+
* // Returns: startDate: Jan 1, endDate: Jan 31, daysInPeriod: 31
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* // Mid-month billing (billing_cycle_day = 15)
|
|
59
|
+
* calculateBillingPeriod(15, new Date('2026-01-20'))
|
|
60
|
+
* // Returns: startDate: Jan 15, endDate: Feb 14, daysInPeriod: 31
|
|
61
|
+
*/
|
|
62
|
+
export function calculateBillingPeriod(
|
|
63
|
+
billingCycleDay: number,
|
|
64
|
+
refDate = new Date()
|
|
65
|
+
): BillingPeriod {
|
|
66
|
+
// Normalise to calendar month if 0 or 1
|
|
67
|
+
const cycleDay = billingCycleDay <= 1 ? 1 : Math.min(billingCycleDay, 28);
|
|
68
|
+
|
|
69
|
+
const year = refDate.getFullYear();
|
|
70
|
+
const month = refDate.getMonth();
|
|
71
|
+
const day = refDate.getDate();
|
|
72
|
+
|
|
73
|
+
let startDate: Date;
|
|
74
|
+
let endDate: Date;
|
|
75
|
+
|
|
76
|
+
if (cycleDay === 1) {
|
|
77
|
+
// Calendar month billing
|
|
78
|
+
startDate = new Date(year, month, 1);
|
|
79
|
+
endDate = new Date(year, month + 1, 0); // Last day of current month
|
|
80
|
+
} else {
|
|
81
|
+
// Mid-month billing
|
|
82
|
+
if (day >= cycleDay) {
|
|
83
|
+
// We're in the period that started this month
|
|
84
|
+
startDate = new Date(year, month, cycleDay);
|
|
85
|
+
endDate = new Date(year, month + 1, cycleDay - 1);
|
|
86
|
+
} else {
|
|
87
|
+
// We're in the period that started last month
|
|
88
|
+
startDate = new Date(year, month - 1, cycleDay);
|
|
89
|
+
endDate = new Date(year, month, cycleDay - 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Calculate days
|
|
94
|
+
const daysInPeriod =
|
|
95
|
+
Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
96
|
+
const daysElapsed =
|
|
97
|
+
Math.round((refDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
98
|
+
const daysRemaining = Math.max(0, daysInPeriod - daysElapsed);
|
|
99
|
+
const progress = Math.min(1, daysElapsed / daysInPeriod);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
startDate,
|
|
103
|
+
endDate,
|
|
104
|
+
daysInPeriod,
|
|
105
|
+
daysElapsed,
|
|
106
|
+
daysRemaining,
|
|
107
|
+
progress,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Prorate a monthly allowance based on query period vs billing period.
|
|
113
|
+
*
|
|
114
|
+
* @param monthlyAllowance - Full monthly allowance (e.g., 10M Workers requests)
|
|
115
|
+
* @param periodDays - Number of days in the query period (e.g., 1 for 24h, 7 for 7d)
|
|
116
|
+
* @param billingDays - Total days in the billing period (default 30)
|
|
117
|
+
* @returns Prorated allowance for the query period
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* // 24h query against 10M monthly allowance
|
|
121
|
+
* prorateAllowance(10_000_000, 1, 30)
|
|
122
|
+
* // Returns: 333,333 (1/30th of monthly)
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* // 7d query against 50M monthly allowance
|
|
126
|
+
* prorateAllowance(50_000_000, 7, 31)
|
|
127
|
+
* // Returns: 11,290,323 (7/31ths of monthly)
|
|
128
|
+
*/
|
|
129
|
+
export function prorateAllowance(
|
|
130
|
+
monthlyAllowance: number,
|
|
131
|
+
periodDays: number,
|
|
132
|
+
billingDays = 30
|
|
133
|
+
): number {
|
|
134
|
+
if (billingDays <= 0) return monthlyAllowance;
|
|
135
|
+
if (periodDays >= billingDays) return monthlyAllowance;
|
|
136
|
+
return Math.round(monthlyAllowance * (periodDays / billingDays));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Calculate billable usage after subtracting prorated allowance.
|
|
141
|
+
*
|
|
142
|
+
* @param usage - Raw usage for the period
|
|
143
|
+
* @param monthlyAllowance - Full monthly allowance
|
|
144
|
+
* @param periodDays - Number of days in the query period
|
|
145
|
+
* @param billingDays - Total days in the billing period (default 30)
|
|
146
|
+
* @returns Object with raw, prorated allowance, billable usage, and percentage
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* // 500K requests in 24h against 10M monthly allowance
|
|
150
|
+
* calculateBillableUsage(500_000, 10_000_000, 1, 30)
|
|
151
|
+
* // Returns: { raw: 500000, proratedAllowance: 333333, billable: 166667, pctOfAllowance: 150 }
|
|
152
|
+
*/
|
|
153
|
+
export function calculateBillableUsage(
|
|
154
|
+
usage: number,
|
|
155
|
+
monthlyAllowance: number,
|
|
156
|
+
periodDays: number,
|
|
157
|
+
billingDays = 30
|
|
158
|
+
): {
|
|
159
|
+
raw: number;
|
|
160
|
+
proratedAllowance: number;
|
|
161
|
+
billable: number;
|
|
162
|
+
pctOfAllowance: number;
|
|
163
|
+
} {
|
|
164
|
+
const proratedAllowance = prorateAllowance(monthlyAllowance, periodDays, billingDays);
|
|
165
|
+
const billable = Math.max(0, usage - proratedAllowance);
|
|
166
|
+
const pctOfAllowance = proratedAllowance > 0 ? (usage / proratedAllowance) * 100 : 0;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
raw: usage,
|
|
170
|
+
proratedAllowance,
|
|
171
|
+
billable,
|
|
172
|
+
pctOfAllowance,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get the default billing settings.
|
|
178
|
+
* Used as fallback when D1 data is unavailable.
|
|
179
|
+
*/
|
|
180
|
+
export function getDefaultBillingSettings(): BillingSettings {
|
|
181
|
+
return {
|
|
182
|
+
accountId: 'default',
|
|
183
|
+
planType: 'paid',
|
|
184
|
+
billingCycleDay: 1, // Calendar month
|
|
185
|
+
billingCurrency: 'USD',
|
|
186
|
+
baseCostMonthly: 5.0, // Workers Paid Plan
|
|
187
|
+
notes: 'Default billing settings',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Calculate fair share allowance allocation for a project.
|
|
193
|
+
*
|
|
194
|
+
* Uses proportional fair share: each project gets a share of the total
|
|
195
|
+
* allowance proportional to their share of total usage.
|
|
196
|
+
*
|
|
197
|
+
* @param projectUsage - Usage for this project
|
|
198
|
+
* @param totalAccountUsage - Total usage across all projects
|
|
199
|
+
* @param monthlyAllowance - Total monthly allowance for the account
|
|
200
|
+
* @returns Object with allowance share and billable usage
|
|
201
|
+
*/
|
|
202
|
+
export function calculateProjectAllowanceShare(
|
|
203
|
+
projectUsage: number,
|
|
204
|
+
totalAccountUsage: number,
|
|
205
|
+
monthlyAllowance: number
|
|
206
|
+
): {
|
|
207
|
+
share: number;
|
|
208
|
+
billable: number;
|
|
209
|
+
proportion: number;
|
|
210
|
+
} {
|
|
211
|
+
if (totalAccountUsage <= 0) {
|
|
212
|
+
return { share: 0, billable: 0, proportion: 0 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const proportion = projectUsage / totalAccountUsage;
|
|
216
|
+
const share = monthlyAllowance * proportion;
|
|
217
|
+
const billable = Math.max(0, projectUsage - share);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
share: Math.round(share),
|
|
221
|
+
billable: Math.round(billable),
|
|
222
|
+
proportion,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Billing window with ISO date strings for SQL queries.
|
|
228
|
+
*/
|
|
229
|
+
export interface BillingWindow {
|
|
230
|
+
/** Start date as YYYY-MM-DD string */
|
|
231
|
+
startDate: string;
|
|
232
|
+
/** End date as YYYY-MM-DD string */
|
|
233
|
+
endDate: string;
|
|
234
|
+
/** Days elapsed in current period */
|
|
235
|
+
daysElapsed: number;
|
|
236
|
+
/** Total days in billing period */
|
|
237
|
+
daysInPeriod: number;
|
|
238
|
+
/** Progress through period (0-1) */
|
|
239
|
+
progress: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get billing window dates for SQL queries.
|
|
244
|
+
*
|
|
245
|
+
* Convenience wrapper around calculateBillingPeriod that returns
|
|
246
|
+
* ISO date strings ready for D1 queries.
|
|
247
|
+
*
|
|
248
|
+
* @param anchorDay - Day of month billing resets (1-28) or 0/1 for calendar month
|
|
249
|
+
* @param refDate - Reference date (defaults to now)
|
|
250
|
+
* @returns Billing window with date strings
|
|
251
|
+
*/
|
|
252
|
+
export function getBillingWindow(anchorDay: number, refDate = new Date()): BillingWindow {
|
|
253
|
+
const period = calculateBillingPeriod(anchorDay, refDate);
|
|
254
|
+
|
|
255
|
+
// Format as YYYY-MM-DD in local time (not UTC) to match D1 date storage
|
|
256
|
+
const formatLocalDate = (date: Date): string => {
|
|
257
|
+
const y = date.getFullYear();
|
|
258
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
259
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
260
|
+
return `${y}-${m}-${d}`;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
startDate: formatLocalDate(period.startDate),
|
|
265
|
+
endDate: formatLocalDate(period.endDate),
|
|
266
|
+
daysElapsed: period.daysElapsed,
|
|
267
|
+
daysInPeriod: period.daysInPeriod,
|
|
268
|
+
progress: period.progress,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Format billing period for display.
|
|
274
|
+
*
|
|
275
|
+
* @param period - Billing period from calculateBillingPeriod
|
|
276
|
+
* @returns Formatted string like "Jan 1 - Jan 31"
|
|
277
|
+
*/
|
|
278
|
+
export function formatBillingPeriod(period: BillingPeriod): string {
|
|
279
|
+
const formatter = new Intl.DateTimeFormat('en-AU', { month: 'short', day: 'numeric' });
|
|
280
|
+
return `${formatter.format(period.startDate)} - ${formatter.format(period.endDate)}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get billing countdown text.
|
|
285
|
+
*
|
|
286
|
+
* @param daysRemaining - Days remaining in billing period
|
|
287
|
+
* @returns Human-readable countdown string
|
|
288
|
+
*/
|
|
289
|
+
export function getBillingCountdownText(daysRemaining: number): string {
|
|
290
|
+
if (daysRemaining <= 0) return 'Billing reset today';
|
|
291
|
+
if (daysRemaining === 1) return '1 day until billing reset';
|
|
292
|
+
return `${daysRemaining} days until billing reset`;
|
|
293
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Circuit Breaker Middleware -- Thin Re-export from Platform SDK
|
|
5
|
+
*
|
|
6
|
+
* All logic lives in @littlebearapps/platform-consumer-sdk/middleware.
|
|
7
|
+
* This file re-exports with the original names for backward compatibility.
|
|
8
|
+
*
|
|
9
|
+
* @see packages/platform-sdk/src/middleware.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Re-export with original names (SDK uses prefixed names)
|
|
13
|
+
export {
|
|
14
|
+
PROJECT_CB_STATUS as CB_STATUS,
|
|
15
|
+
CB_PROJECT_KEYS,
|
|
16
|
+
CB_ERROR_CODES,
|
|
17
|
+
BUDGET_STATUS_HEADER,
|
|
18
|
+
checkProjectCircuitBreaker as checkCircuitBreaker,
|
|
19
|
+
checkProjectCircuitBreakerDetailed as checkCircuitBreakerDetailed,
|
|
20
|
+
createCircuitBreakerMiddleware,
|
|
21
|
+
getCircuitBreakerStates,
|
|
22
|
+
type CircuitBreakerStatusValue,
|
|
23
|
+
type CircuitBreakerCheckResult,
|
|
24
|
+
type CircuitBreakerErrorResponse,
|
|
25
|
+
} from '@littlebearapps/platform-consumer-sdk/middleware';
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PID Controller for Intelligent Degradation
|
|
5
|
+
*
|
|
6
|
+
* Provides smooth throttle rate calculation (0.0-1.0) instead of binary ON/OFF.
|
|
7
|
+
* State is stored in KV, making the controller stateless per invocation.
|
|
8
|
+
*
|
|
9
|
+
* Key principle: PID provides smooth degradation, circuit breakers remain the emergency stop.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// TYPES
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* PID controller gains and configuration.
|
|
18
|
+
* Tuned for budget-based throttling where:
|
|
19
|
+
* - setpoint (0.70) = target 70% budget utilisation
|
|
20
|
+
* - Output range [0, 1] = throttle rate (0=no throttle, 1=full throttle)
|
|
21
|
+
*/
|
|
22
|
+
export interface PIDConfig {
|
|
23
|
+
/** Proportional gain - responds to current error */
|
|
24
|
+
kp: number;
|
|
25
|
+
/** Integral gain - responds to accumulated error */
|
|
26
|
+
ki: number;
|
|
27
|
+
/** Derivative gain - responds to rate of change */
|
|
28
|
+
kd: number;
|
|
29
|
+
/** Target budget utilisation (0.0-1.0) */
|
|
30
|
+
setpoint: number;
|
|
31
|
+
/** Minimum output value (default: 0) */
|
|
32
|
+
outputMin: number;
|
|
33
|
+
/** Maximum output value (default: 1) */
|
|
34
|
+
outputMax: number;
|
|
35
|
+
/** Maximum integral accumulation to prevent windup */
|
|
36
|
+
integralMax: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Persisted PID state stored in KV.
|
|
41
|
+
* Key: STATE:PID:{feature_id}
|
|
42
|
+
*/
|
|
43
|
+
export interface PIDState {
|
|
44
|
+
/** Accumulated error (integral term) */
|
|
45
|
+
integral: number;
|
|
46
|
+
/** Previous error for derivative calculation */
|
|
47
|
+
prevError: number;
|
|
48
|
+
/** Last update timestamp (ms) */
|
|
49
|
+
lastUpdate: number;
|
|
50
|
+
/** Current throttle rate output */
|
|
51
|
+
throttleRate: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Input for PID computation.
|
|
56
|
+
*/
|
|
57
|
+
export interface PIDInput {
|
|
58
|
+
/** Current budget utilisation (0.0-1.0+, can exceed 1.0 if over budget) */
|
|
59
|
+
currentUsage: number;
|
|
60
|
+
/** Time since last update in milliseconds */
|
|
61
|
+
deltaTimeMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Output from PID computation.
|
|
66
|
+
*/
|
|
67
|
+
export interface PIDOutput {
|
|
68
|
+
/** Computed throttle rate (0.0-1.0) */
|
|
69
|
+
throttleRate: number;
|
|
70
|
+
/** Updated state to persist */
|
|
71
|
+
newState: PIDState;
|
|
72
|
+
/** Debug info for monitoring */
|
|
73
|
+
debug: {
|
|
74
|
+
error: number;
|
|
75
|
+
pTerm: number;
|
|
76
|
+
iTerm: number;
|
|
77
|
+
dTerm: number;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// DEFAULT CONFIGURATION
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Default PID configuration tuned for budget-based throttling.
|
|
87
|
+
*
|
|
88
|
+
* Rationale:
|
|
89
|
+
* - kp=0.5: Moderate response to current error (50% of error -> throttle change)
|
|
90
|
+
* - ki=0.1: Slow integral to avoid oscillation, correct steady-state error
|
|
91
|
+
* - kd=0.05: Light derivative to dampen sudden changes
|
|
92
|
+
* - setpoint=0.70: Target 70% budget utilisation, leaving 30% headroom
|
|
93
|
+
* - integralMax=2.0: Prevent integral windup during sustained over-budget
|
|
94
|
+
*/
|
|
95
|
+
export const DEFAULT_PID_CONFIG: PIDConfig = {
|
|
96
|
+
kp: 0.5,
|
|
97
|
+
ki: 0.1,
|
|
98
|
+
kd: 0.05,
|
|
99
|
+
setpoint: 0.7,
|
|
100
|
+
outputMin: 0,
|
|
101
|
+
outputMax: 1,
|
|
102
|
+
integralMax: 2.0,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create a fresh PID state (for new features or reset).
|
|
107
|
+
*/
|
|
108
|
+
export function createPIDState(): PIDState {
|
|
109
|
+
return {
|
|
110
|
+
integral: 0,
|
|
111
|
+
prevError: 0,
|
|
112
|
+
lastUpdate: Date.now(),
|
|
113
|
+
throttleRate: 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// PID COMPUTATION
|
|
119
|
+
// =============================================================================
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compute PID output for throttle rate.
|
|
123
|
+
*
|
|
124
|
+
* This is a stateless function - pass in current state, get new state back.
|
|
125
|
+
* The caller is responsible for persisting state to KV.
|
|
126
|
+
*
|
|
127
|
+
* @param state - Current PID state from KV (or fresh state for new features)
|
|
128
|
+
* @param input - Current usage and timing information
|
|
129
|
+
* @param config - PID configuration (defaults provided)
|
|
130
|
+
* @returns New throttle rate and updated state
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* const state = await getPIDState(featureId, env);
|
|
135
|
+
* const input = { currentUsage: 0.85, deltaTimeMs: 60000 };
|
|
136
|
+
* const output = computePID(state, input);
|
|
137
|
+
* await savePIDState(featureId, output.newState, env);
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function computePID(
|
|
141
|
+
state: PIDState,
|
|
142
|
+
input: PIDInput,
|
|
143
|
+
config: PIDConfig = DEFAULT_PID_CONFIG
|
|
144
|
+
): PIDOutput {
|
|
145
|
+
// Calculate error: positive = over budget, negative = under budget
|
|
146
|
+
// error > 0 means we need MORE throttling
|
|
147
|
+
const error = input.currentUsage - config.setpoint;
|
|
148
|
+
|
|
149
|
+
// Convert deltaTime to seconds for consistent gains regardless of update frequency
|
|
150
|
+
const dt = Math.max(input.deltaTimeMs / 1000, 0.001); // Minimum 1ms to avoid division issues
|
|
151
|
+
|
|
152
|
+
// Proportional term: immediate response to current error
|
|
153
|
+
const pTerm = config.kp * error;
|
|
154
|
+
|
|
155
|
+
// Integral term: accumulated error over time (with anti-windup)
|
|
156
|
+
let newIntegral = state.integral + error * dt;
|
|
157
|
+
// Clamp integral to prevent windup
|
|
158
|
+
newIntegral = Math.max(-config.integralMax, Math.min(config.integralMax, newIntegral));
|
|
159
|
+
const iTerm = config.ki * newIntegral;
|
|
160
|
+
|
|
161
|
+
// Derivative term: rate of change of error (damping)
|
|
162
|
+
const derivative = (error - state.prevError) / dt;
|
|
163
|
+
const dTerm = config.kd * derivative;
|
|
164
|
+
|
|
165
|
+
// Sum all terms
|
|
166
|
+
let output = pTerm + iTerm + dTerm;
|
|
167
|
+
|
|
168
|
+
// Clamp output to valid range
|
|
169
|
+
output = Math.max(config.outputMin, Math.min(config.outputMax, output));
|
|
170
|
+
|
|
171
|
+
// Create new state
|
|
172
|
+
const newState: PIDState = {
|
|
173
|
+
integral: newIntegral,
|
|
174
|
+
prevError: error,
|
|
175
|
+
lastUpdate: Date.now(),
|
|
176
|
+
throttleRate: output,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
throttleRate: output,
|
|
181
|
+
newState,
|
|
182
|
+
debug: {
|
|
183
|
+
error,
|
|
184
|
+
pTerm,
|
|
185
|
+
iTerm,
|
|
186
|
+
dTerm,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// =============================================================================
|
|
192
|
+
// KV PERSISTENCE HELPERS
|
|
193
|
+
// =============================================================================
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* KV key for PID state.
|
|
197
|
+
*/
|
|
198
|
+
export function pidStateKey(featureId: string): string {
|
|
199
|
+
return `STATE:PID:${featureId}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* KV key for throttle rate (read by SDK).
|
|
204
|
+
*/
|
|
205
|
+
export function throttleRateKey(featureId: string): string {
|
|
206
|
+
return `CONFIG:FEATURE:${featureId}:THROTTLE_RATE`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get PID state from KV, returning fresh state if not found.
|
|
211
|
+
*/
|
|
212
|
+
export async function getPIDState(featureId: string, kv: KVNamespace): Promise<PIDState> {
|
|
213
|
+
const key = pidStateKey(featureId);
|
|
214
|
+
const data = await kv.get(key, 'json');
|
|
215
|
+
if (data && typeof data === 'object') {
|
|
216
|
+
return data as PIDState;
|
|
217
|
+
}
|
|
218
|
+
return createPIDState();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Save PID state to KV with 24h TTL.
|
|
223
|
+
* Also writes throttle rate to separate key with 5min TTL for SDK consumption.
|
|
224
|
+
*/
|
|
225
|
+
export async function savePIDState(
|
|
226
|
+
featureId: string,
|
|
227
|
+
state: PIDState,
|
|
228
|
+
kv: KVNamespace
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const stateKey = pidStateKey(featureId);
|
|
231
|
+
const rateKey = throttleRateKey(featureId);
|
|
232
|
+
|
|
233
|
+
// Save state with 24h TTL (for persistence across updates)
|
|
234
|
+
await kv.put(stateKey, JSON.stringify(state), { expirationTtl: 86400 });
|
|
235
|
+
|
|
236
|
+
// Save throttle rate separately with 5min TTL (for SDK quick access)
|
|
237
|
+
// Only write if throttle rate > 0 to avoid unnecessary KV writes
|
|
238
|
+
if (state.throttleRate > 0.001) {
|
|
239
|
+
await kv.put(rateKey, state.throttleRate.toString(), { expirationTtl: 300 });
|
|
240
|
+
} else {
|
|
241
|
+
// Delete the key if throttle rate is essentially 0
|
|
242
|
+
await kv.delete(rateKey);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get current throttle rate for SDK consumption.
|
|
248
|
+
* Returns 0 if no throttle rate is set.
|
|
249
|
+
*/
|
|
250
|
+
export async function getThrottleRate(featureId: string, kv: KVNamespace): Promise<number> {
|
|
251
|
+
const key = throttleRateKey(featureId);
|
|
252
|
+
const value = await kv.get(key);
|
|
253
|
+
if (value) {
|
|
254
|
+
const rate = parseFloat(value);
|
|
255
|
+
return isNaN(rate) ? 0 : Math.max(0, Math.min(1, rate));
|
|
256
|
+
}
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// =============================================================================
|
|
261
|
+
// UTILITY FUNCTIONS
|
|
262
|
+
// =============================================================================
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Calculate budget utilisation from current usage and limit.
|
|
266
|
+
*
|
|
267
|
+
* @param currentUsage - Current period usage value
|
|
268
|
+
* @param budgetLimit - Budget limit for the period
|
|
269
|
+
* @returns Utilisation ratio (0.0-1.0+, can exceed 1.0 if over budget)
|
|
270
|
+
*/
|
|
271
|
+
export function calculateUtilisation(currentUsage: number, budgetLimit: number): number {
|
|
272
|
+
if (budgetLimit <= 0) return 0;
|
|
273
|
+
return currentUsage / budgetLimit;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Determine if PID update is needed based on time since last update.
|
|
278
|
+
*
|
|
279
|
+
* @param lastUpdate - Timestamp of last PID update (ms)
|
|
280
|
+
* @param minIntervalMs - Minimum interval between updates (default: 60s)
|
|
281
|
+
* @returns True if update is due
|
|
282
|
+
*/
|
|
283
|
+
export function shouldUpdatePID(lastUpdate: number, minIntervalMs: number = 60_000): boolean {
|
|
284
|
+
return Date.now() - lastUpdate >= minIntervalMs;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Format throttle rate for logging (percentage with 1 decimal).
|
|
289
|
+
*/
|
|
290
|
+
export function formatThrottleRate(rate: number): string {
|
|
291
|
+
return `${(rate * 100).toFixed(1)}%`;
|
|
292
|
+
}
|