@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,2420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Metrics Handler Module
|
|
3
|
+
*
|
|
4
|
+
* Handles usage metrics endpoints for the platform-usage worker.
|
|
5
|
+
* Extracted from platform-usage.ts as part of Phase B migration.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints handled:
|
|
8
|
+
* - GET /usage - Get usage metrics with cost breakdown
|
|
9
|
+
* - GET /usage/costs - Get cost breakdown only
|
|
10
|
+
* - GET /usage/thresholds - Get threshold warnings only
|
|
11
|
+
* - GET /usage/enhanced - Get enhanced usage metrics with sparklines and trends
|
|
12
|
+
* - GET /usage/compare - Get period comparison (task-17.3, 17.4)
|
|
13
|
+
* - GET /usage/daily - Get daily cost breakdown for chart/table (task-18)
|
|
14
|
+
* - GET /usage/status - Project status for unified dashboard
|
|
15
|
+
* - GET /usage/projects - Project list with resource counts
|
|
16
|
+
* - GET /usage/anomalies - Detected usage anomalies
|
|
17
|
+
* - GET /usage/utilization - Burn rate and per-project utilization
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
CloudflareGraphQL,
|
|
22
|
+
calculateMonthlyCosts,
|
|
23
|
+
calculateProjectCosts,
|
|
24
|
+
analyseThresholds,
|
|
25
|
+
formatCurrency,
|
|
26
|
+
getProjects,
|
|
27
|
+
type TimePeriod,
|
|
28
|
+
type DateRange,
|
|
29
|
+
type CompareMode,
|
|
30
|
+
type AccountUsage,
|
|
31
|
+
type CostBreakdown,
|
|
32
|
+
type ProjectCostBreakdown,
|
|
33
|
+
type Project,
|
|
34
|
+
} from '../../shared/cloudflare';
|
|
35
|
+
import {
|
|
36
|
+
CF_SIMPLE_ALLOWANCES,
|
|
37
|
+
type SimpleAllowanceType,
|
|
38
|
+
} from '../../shared/allowances';
|
|
39
|
+
import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
|
|
40
|
+
import { queryD1UsageData, queryD1DailyCosts, calculateProjectedBurn } from './data-queries';
|
|
41
|
+
import {
|
|
42
|
+
type Env,
|
|
43
|
+
type UsageResponse,
|
|
44
|
+
type EnhancedUsageResponse,
|
|
45
|
+
type ComparisonResponse,
|
|
46
|
+
type DailyCostResponse,
|
|
47
|
+
type BurnRateResponse,
|
|
48
|
+
type ProjectUtilizationData,
|
|
49
|
+
type GitHubUsageResponse,
|
|
50
|
+
type ResourceMetricData,
|
|
51
|
+
type ProviderHealthData,
|
|
52
|
+
type ServiceUtilizationStatus,
|
|
53
|
+
type AnomalyRecord,
|
|
54
|
+
type AnomaliesResponse,
|
|
55
|
+
type ProjectedBurn,
|
|
56
|
+
type DailyCostData,
|
|
57
|
+
type TimePeriod as SharedTimePeriod,
|
|
58
|
+
getCacheKey,
|
|
59
|
+
parseQueryParams,
|
|
60
|
+
parseQueryParamsWithRegistry,
|
|
61
|
+
jsonResponse,
|
|
62
|
+
buildProjectLookupCache,
|
|
63
|
+
filterByProject,
|
|
64
|
+
filterByProjectWithRegistry,
|
|
65
|
+
calculateSummary,
|
|
66
|
+
calcTrend,
|
|
67
|
+
getBudgetThresholds,
|
|
68
|
+
getServiceUtilizationStatus,
|
|
69
|
+
CB_KEYS,
|
|
70
|
+
CF_OVERAGE_PRICING,
|
|
71
|
+
FALLBACK_PROJECT_CONFIGS,
|
|
72
|
+
} from '../shared';
|
|
73
|
+
import { getUtilizationStatus } from '../../platform-settings';
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// LOCAL TYPE DEFINITIONS
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Projected cost calculation based on MTD usage
|
|
81
|
+
*/
|
|
82
|
+
interface ProjectedCost {
|
|
83
|
+
/** Current month-to-date cost in USD */
|
|
84
|
+
currentCost: number;
|
|
85
|
+
/** Number of days elapsed this month */
|
|
86
|
+
daysPassed: number;
|
|
87
|
+
/** Total days in the current month */
|
|
88
|
+
daysInMonth: number;
|
|
89
|
+
/** Projected end-of-month cost based on current burn rate */
|
|
90
|
+
projectedMonthlyCost: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Service allowance definition for API response
|
|
95
|
+
*/
|
|
96
|
+
interface AllowanceInfo {
|
|
97
|
+
limit: number;
|
|
98
|
+
unit: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Project list response type
|
|
103
|
+
*/
|
|
104
|
+
interface ProjectListResponse {
|
|
105
|
+
success: boolean;
|
|
106
|
+
projects: Array<Project & { resourceCount: number }>;
|
|
107
|
+
totalResources: number;
|
|
108
|
+
timestamp: string;
|
|
109
|
+
cached: boolean;
|
|
110
|
+
/** Cloudflare account-level service allowances */
|
|
111
|
+
allowances: {
|
|
112
|
+
workers: AllowanceInfo;
|
|
113
|
+
d1_writes: AllowanceInfo;
|
|
114
|
+
kv_writes: AllowanceInfo;
|
|
115
|
+
r2_storage: AllowanceInfo;
|
|
116
|
+
durableObjects: AllowanceInfo;
|
|
117
|
+
vectorize: AllowanceInfo;
|
|
118
|
+
github_actions_minutes: AllowanceInfo;
|
|
119
|
+
};
|
|
120
|
+
/** Projected monthly cost based on MTD burn rate */
|
|
121
|
+
projectedCost: ProjectedCost;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// =============================================================================
|
|
125
|
+
// CLOUDFLARE ALLOWANCES ALIAS
|
|
126
|
+
// =============================================================================
|
|
127
|
+
|
|
128
|
+
const CF_ALLOWANCES = CF_SIMPLE_ALLOWANCES;
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// HELPER FUNCTIONS
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get project config from D1 registry or fallback to hardcoded config.
|
|
136
|
+
* Once migration 009 is applied, this will primarily use D1.
|
|
137
|
+
*/
|
|
138
|
+
function getProjectConfig(
|
|
139
|
+
project: Project
|
|
140
|
+
): { name: string; primaryResource: SimpleAllowanceType; customLimit?: number } | null {
|
|
141
|
+
// Use D1 registry values if available
|
|
142
|
+
if (project.primaryResource) {
|
|
143
|
+
const primaryResource = project.primaryResource as SimpleAllowanceType;
|
|
144
|
+
if (CF_ALLOWANCES[primaryResource]) {
|
|
145
|
+
return {
|
|
146
|
+
name: project.displayName,
|
|
147
|
+
primaryResource,
|
|
148
|
+
customLimit: project.customLimit ?? undefined,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fallback to hardcoded config
|
|
154
|
+
return FALLBACK_PROJECT_CONFIGS[project.projectId] ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Query GitHub usage data from D1 third_party_usage table.
|
|
159
|
+
* Returns MTD aggregated data for display in the dashboard.
|
|
160
|
+
*/
|
|
161
|
+
async function queryGitHubUsage(env: Env): Promise<GitHubUsageResponse | null> {
|
|
162
|
+
try {
|
|
163
|
+
const now = new Date();
|
|
164
|
+
const currentYear = now.getUTCFullYear();
|
|
165
|
+
const currentMonth = now.getUTCMonth();
|
|
166
|
+
const mtdStartDate = new Date(Date.UTC(currentYear, currentMonth, 1))
|
|
167
|
+
.toISOString()
|
|
168
|
+
.slice(0, 10);
|
|
169
|
+
|
|
170
|
+
// Query all GitHub metrics for the current month
|
|
171
|
+
const result = await env.PLATFORM_DB.prepare(
|
|
172
|
+
`
|
|
173
|
+
SELECT resource_type, usage_value, cost_usd, usage_unit,
|
|
174
|
+
MAX(snapshot_date) as latest_date,
|
|
175
|
+
MAX(collection_timestamp) as latest_ts
|
|
176
|
+
FROM third_party_usage
|
|
177
|
+
WHERE provider = 'github'
|
|
178
|
+
AND snapshot_date >= ?
|
|
179
|
+
GROUP BY resource_type
|
|
180
|
+
`
|
|
181
|
+
)
|
|
182
|
+
.bind(mtdStartDate)
|
|
183
|
+
.all<{
|
|
184
|
+
resource_type: string;
|
|
185
|
+
usage_value: number;
|
|
186
|
+
cost_usd: number;
|
|
187
|
+
usage_unit: string;
|
|
188
|
+
latest_date: string;
|
|
189
|
+
latest_ts: number;
|
|
190
|
+
}>();
|
|
191
|
+
|
|
192
|
+
if (!result.results || result.results.length === 0) {
|
|
193
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:github');
|
|
194
|
+
log.info('No GitHub data found for current month', { tag: 'GITHUB_NO_DATA' });
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build lookup map
|
|
199
|
+
const metrics: Record<string, { value: number; cost: number; unit: string }> = {};
|
|
200
|
+
let latestTimestamp: number | null = null;
|
|
201
|
+
|
|
202
|
+
for (const row of result.results) {
|
|
203
|
+
metrics[row.resource_type] = {
|
|
204
|
+
value: row.usage_value,
|
|
205
|
+
cost: row.cost_usd,
|
|
206
|
+
unit: row.usage_unit,
|
|
207
|
+
};
|
|
208
|
+
if (row.latest_ts && (latestTimestamp === null || row.latest_ts > latestTimestamp)) {
|
|
209
|
+
latestTimestamp = row.latest_ts;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check if data is stale (older than 24 hours)
|
|
214
|
+
const twentyFourHoursAgo = Date.now() / 1000 - 24 * 60 * 60;
|
|
215
|
+
const isStale = latestTimestamp ? latestTimestamp < twentyFourHoursAgo : true;
|
|
216
|
+
|
|
217
|
+
// Extract values with defaults
|
|
218
|
+
const actionsMinutes = metrics['actions_minutes']?.value ?? 0;
|
|
219
|
+
const actionsMinutesIncluded = metrics['actions_minutes_included']?.value ?? 50000; // Default to Enterprise
|
|
220
|
+
const actionsMinutesUsagePct =
|
|
221
|
+
metrics['actions_minutes_usage_pct']?.value ??
|
|
222
|
+
(actionsMinutesIncluded > 0 ? (actionsMinutes / actionsMinutesIncluded) * 100 : 0);
|
|
223
|
+
const actionsStorageGbHours = metrics['actions_storage_gb_hours']?.value ?? 0;
|
|
224
|
+
const actionsStorageGbIncluded = metrics['actions_storage_gb_included']?.value ?? 50; // Default to Enterprise
|
|
225
|
+
const ghecUserMonths = metrics['ghec_user_months']?.value ?? 0;
|
|
226
|
+
const ghasCodeSecuritySeats = metrics['ghas_code_security_user_months']?.value ?? 0;
|
|
227
|
+
const ghasSecretProtectionSeats = metrics['ghas_secret_protection_user_months']?.value ?? 0;
|
|
228
|
+
const totalCost = metrics['total_net_cost']?.value ?? 0;
|
|
229
|
+
|
|
230
|
+
// Plan info
|
|
231
|
+
const planName = metrics['plan_name']?.unit ?? 'unknown'; // plan_name stores the name in usage_unit
|
|
232
|
+
const filledSeats = metrics['filled_seats']?.value ?? 0;
|
|
233
|
+
const totalSeats = metrics['total_seats']?.value ?? 0;
|
|
234
|
+
|
|
235
|
+
const response: GitHubUsageResponse = {
|
|
236
|
+
mtdUsage: {
|
|
237
|
+
actionsMinutes: Math.round(actionsMinutes),
|
|
238
|
+
actionsMinutesIncluded: Math.round(actionsMinutesIncluded),
|
|
239
|
+
actionsMinutesUsagePct: Math.round(actionsMinutesUsagePct * 10) / 10,
|
|
240
|
+
actionsStorageGbHours: Math.round(actionsStorageGbHours * 100) / 100,
|
|
241
|
+
actionsStorageGbIncluded: actionsStorageGbIncluded,
|
|
242
|
+
ghecUserMonths: Math.round(ghecUserMonths * 100) / 100,
|
|
243
|
+
ghasCodeSecuritySeats: Math.round(ghasCodeSecuritySeats),
|
|
244
|
+
ghasSecretProtectionSeats: Math.round(ghasSecretProtectionSeats),
|
|
245
|
+
totalCost: Math.round(totalCost * 100) / 100,
|
|
246
|
+
},
|
|
247
|
+
plan: {
|
|
248
|
+
name: planName,
|
|
249
|
+
filledSeats: Math.round(filledSeats),
|
|
250
|
+
totalSeats: Math.round(totalSeats),
|
|
251
|
+
},
|
|
252
|
+
lastUpdated: latestTimestamp ? new Date(latestTimestamp * 1000).toISOString() : null,
|
|
253
|
+
isStale,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:github');
|
|
257
|
+
log.info(
|
|
258
|
+
`GitHub data: ${response.mtdUsage.actionsMinutes}/${response.mtdUsage.actionsMinutesIncluded} mins (${response.mtdUsage.actionsMinutesUsagePct}%), $${response.mtdUsage.totalCost}`,
|
|
259
|
+
{ tag: 'USAGE' }
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
return response;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:github');
|
|
265
|
+
log.error(
|
|
266
|
+
'Error querying GitHub usage',
|
|
267
|
+
error instanceof Error ? error : new Error(String(error))
|
|
268
|
+
);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Build service-level utilization metrics from D1 totals.
|
|
275
|
+
* Returns CloudFlare service utilization data for the usage overview.
|
|
276
|
+
*/
|
|
277
|
+
function buildCloudflareServiceMetrics(
|
|
278
|
+
totals: DailyCostData['totals'],
|
|
279
|
+
dayOfMonth: number
|
|
280
|
+
): ResourceMetricData[] {
|
|
281
|
+
const metrics: ResourceMetricData[] = [];
|
|
282
|
+
|
|
283
|
+
// Workers Requests (estimated from cost)
|
|
284
|
+
const workersRequests = totals.workers > 0 ? Math.round((totals.workers / 0.3) * 1_000_000) : 0;
|
|
285
|
+
const workersLimit = CF_ALLOWANCES.workers.limit;
|
|
286
|
+
const workersPct = workersLimit > 0 ? (workersRequests / workersLimit) * 100 : 0;
|
|
287
|
+
const workersOverage = Math.max(0, workersRequests - workersLimit);
|
|
288
|
+
metrics.push({
|
|
289
|
+
id: 'cf-workers',
|
|
290
|
+
label: 'Workers Requests',
|
|
291
|
+
provider: 'cloudflare',
|
|
292
|
+
current: workersRequests,
|
|
293
|
+
limit: workersLimit,
|
|
294
|
+
unit: 'requests',
|
|
295
|
+
percentage: Math.round(workersPct * 10) / 10,
|
|
296
|
+
costEstimate: totals.workers,
|
|
297
|
+
status: getServiceUtilizationStatus(workersPct),
|
|
298
|
+
overage: workersOverage,
|
|
299
|
+
overageCost: workersOverage * (CF_OVERAGE_PRICING.workers ?? 0),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// D1 Writes (estimated from cost)
|
|
303
|
+
const d1Writes = totals.d1 > 0 ? Math.round(totals.d1 * 1_000_000) : 0;
|
|
304
|
+
const d1Limit = CF_ALLOWANCES.d1.limit;
|
|
305
|
+
const d1Pct = d1Limit > 0 ? (d1Writes / d1Limit) * 100 : 0;
|
|
306
|
+
const d1Overage = Math.max(0, d1Writes - d1Limit);
|
|
307
|
+
metrics.push({
|
|
308
|
+
id: 'cf-d1',
|
|
309
|
+
label: 'D1 Writes',
|
|
310
|
+
provider: 'cloudflare',
|
|
311
|
+
current: d1Writes,
|
|
312
|
+
limit: d1Limit,
|
|
313
|
+
unit: 'rows',
|
|
314
|
+
percentage: Math.round(d1Pct * 10) / 10,
|
|
315
|
+
costEstimate: totals.d1,
|
|
316
|
+
status: getServiceUtilizationStatus(d1Pct),
|
|
317
|
+
overage: d1Overage,
|
|
318
|
+
overageCost: d1Overage * (CF_OVERAGE_PRICING.d1 ?? 0),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// KV Writes (estimated from cost)
|
|
322
|
+
const kvWrites = totals.kv > 0 ? Math.round((totals.kv / 5) * 1_000_000) : 0;
|
|
323
|
+
const kvLimit = CF_ALLOWANCES.kv.limit;
|
|
324
|
+
const kvPct = kvLimit > 0 ? (kvWrites / kvLimit) * 100 : 0;
|
|
325
|
+
const kvOverage = Math.max(0, kvWrites - kvLimit);
|
|
326
|
+
metrics.push({
|
|
327
|
+
id: 'cf-kv',
|
|
328
|
+
label: 'KV Writes',
|
|
329
|
+
provider: 'cloudflare',
|
|
330
|
+
current: kvWrites,
|
|
331
|
+
limit: kvLimit,
|
|
332
|
+
unit: 'writes',
|
|
333
|
+
percentage: Math.round(kvPct * 10) / 10,
|
|
334
|
+
costEstimate: totals.kv,
|
|
335
|
+
status: getServiceUtilizationStatus(kvPct),
|
|
336
|
+
overage: kvOverage,
|
|
337
|
+
overageCost: kvOverage * (CF_OVERAGE_PRICING.kv ?? 0),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// R2 Storage (estimated - assuming cost reflects storage)
|
|
341
|
+
const r2Bytes = totals.r2 > 0 ? Math.round((totals.r2 / 0.015) * 1_000_000_000) : 0;
|
|
342
|
+
const r2Limit = CF_ALLOWANCES.r2.limit;
|
|
343
|
+
const r2Pct = r2Limit > 0 ? (r2Bytes / r2Limit) * 100 : 0;
|
|
344
|
+
const r2Overage = Math.max(0, r2Bytes - r2Limit);
|
|
345
|
+
metrics.push({
|
|
346
|
+
id: 'cf-r2',
|
|
347
|
+
label: 'R2 Storage',
|
|
348
|
+
provider: 'cloudflare',
|
|
349
|
+
current: r2Bytes,
|
|
350
|
+
limit: r2Limit,
|
|
351
|
+
unit: 'bytes',
|
|
352
|
+
percentage: Math.round(r2Pct * 10) / 10,
|
|
353
|
+
costEstimate: totals.r2,
|
|
354
|
+
status: getServiceUtilizationStatus(r2Pct),
|
|
355
|
+
overage: r2Overage,
|
|
356
|
+
overageCost: (r2Overage / 1_000_000_000) * (CF_OVERAGE_PRICING.r2 ?? 0),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Durable Objects Requests
|
|
360
|
+
const doRequests = totals.durableObjects > 0 ? Math.round(totals.durableObjects * 1_000_000) : 0;
|
|
361
|
+
const doLimit = CF_ALLOWANCES.durableObjects.limit;
|
|
362
|
+
const doPct = doLimit > 0 ? (doRequests / doLimit) * 100 : 0;
|
|
363
|
+
const doOverage = Math.max(0, doRequests - doLimit);
|
|
364
|
+
metrics.push({
|
|
365
|
+
id: 'cf-do',
|
|
366
|
+
label: 'Durable Objects',
|
|
367
|
+
provider: 'cloudflare',
|
|
368
|
+
current: doRequests,
|
|
369
|
+
limit: doLimit,
|
|
370
|
+
unit: 'requests',
|
|
371
|
+
percentage: Math.round(doPct * 10) / 10,
|
|
372
|
+
costEstimate: totals.durableObjects,
|
|
373
|
+
status: getServiceUtilizationStatus(doPct),
|
|
374
|
+
overage: doOverage,
|
|
375
|
+
overageCost: doOverage * (CF_OVERAGE_PRICING.durableObjects ?? 0),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Vectorize Dimensions
|
|
379
|
+
const vectorizeDims =
|
|
380
|
+
totals.vectorize > 0 ? Math.round((totals.vectorize / 0.01) * 1_000_000) : 0;
|
|
381
|
+
const vectorizeLimit = CF_ALLOWANCES.vectorize.limit;
|
|
382
|
+
const vectorizePct = vectorizeLimit > 0 ? (vectorizeDims / vectorizeLimit) * 100 : 0;
|
|
383
|
+
const vectorizeOverage = Math.max(0, vectorizeDims - vectorizeLimit);
|
|
384
|
+
metrics.push({
|
|
385
|
+
id: 'cf-vectorize',
|
|
386
|
+
label: 'Vectorize Dimensions',
|
|
387
|
+
provider: 'cloudflare',
|
|
388
|
+
current: vectorizeDims,
|
|
389
|
+
limit: vectorizeLimit,
|
|
390
|
+
unit: 'dimensions',
|
|
391
|
+
percentage: Math.round(vectorizePct * 10) / 10,
|
|
392
|
+
costEstimate: totals.vectorize,
|
|
393
|
+
status: getServiceUtilizationStatus(vectorizePct),
|
|
394
|
+
overage: vectorizeOverage,
|
|
395
|
+
overageCost: vectorizeOverage * (CF_OVERAGE_PRICING.vectorize ?? 0),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Workers AI Neurons
|
|
399
|
+
const aiNeurons = totals.workersAI > 0 ? Math.round((totals.workersAI / 0.011) * 1000) : 0;
|
|
400
|
+
const aiLimit = CF_ALLOWANCES.workersAI.limit * dayOfMonth; // Daily limit x days
|
|
401
|
+
const aiPct = aiLimit > 0 ? (aiNeurons / aiLimit) * 100 : 0;
|
|
402
|
+
const aiOverage = Math.max(0, aiNeurons - aiLimit);
|
|
403
|
+
metrics.push({
|
|
404
|
+
id: 'cf-workers-ai',
|
|
405
|
+
label: 'Workers AI',
|
|
406
|
+
provider: 'cloudflare',
|
|
407
|
+
current: aiNeurons,
|
|
408
|
+
limit: aiLimit,
|
|
409
|
+
unit: 'neurons',
|
|
410
|
+
percentage: Math.round(aiPct * 10) / 10,
|
|
411
|
+
costEstimate: totals.workersAI,
|
|
412
|
+
status: getServiceUtilizationStatus(aiPct),
|
|
413
|
+
overage: aiOverage,
|
|
414
|
+
overageCost: (aiOverage / 1000) * (CF_OVERAGE_PRICING.workersAI ?? 0),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Queues Messages
|
|
418
|
+
const queuesMsgs = totals.queues > 0 ? Math.round((totals.queues / 0.4) * 1_000_000) : 0;
|
|
419
|
+
const queuesLimit = CF_ALLOWANCES.queues.limit;
|
|
420
|
+
const queuesPct = queuesLimit > 0 ? (queuesMsgs / queuesLimit) * 100 : 0;
|
|
421
|
+
const queuesOverage = Math.max(0, queuesMsgs - queuesLimit);
|
|
422
|
+
metrics.push({
|
|
423
|
+
id: 'cf-queues',
|
|
424
|
+
label: 'Queues Messages',
|
|
425
|
+
provider: 'cloudflare',
|
|
426
|
+
current: queuesMsgs,
|
|
427
|
+
limit: queuesLimit,
|
|
428
|
+
unit: 'messages',
|
|
429
|
+
percentage: Math.round(queuesPct * 10) / 10,
|
|
430
|
+
costEstimate: totals.queues,
|
|
431
|
+
status: getServiceUtilizationStatus(queuesPct),
|
|
432
|
+
overage: queuesOverage,
|
|
433
|
+
overageCost: queuesOverage * (CF_OVERAGE_PRICING.queues ?? 0),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// AI Gateway (unlimited, just show usage)
|
|
437
|
+
metrics.push({
|
|
438
|
+
id: 'cf-ai-gateway',
|
|
439
|
+
label: 'AI Gateway',
|
|
440
|
+
provider: 'cloudflare',
|
|
441
|
+
current: 0, // Not tracked in rollups
|
|
442
|
+
limit: null, // Unlimited
|
|
443
|
+
unit: 'requests',
|
|
444
|
+
percentage: 0,
|
|
445
|
+
costEstimate: totals.aiGateway,
|
|
446
|
+
status: 'ok',
|
|
447
|
+
overage: 0,
|
|
448
|
+
overageCost: 0,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return metrics.filter((m) => m.current > 0 || m.costEstimate > 0);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Build GitHub service metrics from third_party_usage data.
|
|
456
|
+
*/
|
|
457
|
+
function buildGitHubServiceMetrics(github: GitHubUsageResponse | null): ResourceMetricData[] {
|
|
458
|
+
if (!github) return [];
|
|
459
|
+
|
|
460
|
+
const metrics: ResourceMetricData[] = [];
|
|
461
|
+
const usage = github.mtdUsage;
|
|
462
|
+
|
|
463
|
+
// Actions Minutes
|
|
464
|
+
const actionsLimit = usage.actionsMinutesIncluded || 50000;
|
|
465
|
+
const actionsPct = actionsLimit > 0 ? (usage.actionsMinutes / actionsLimit) * 100 : 0;
|
|
466
|
+
const actionsOverage = Math.max(0, usage.actionsMinutes - actionsLimit);
|
|
467
|
+
metrics.push({
|
|
468
|
+
id: 'gh-actions-minutes',
|
|
469
|
+
label: 'Actions Minutes',
|
|
470
|
+
provider: 'github',
|
|
471
|
+
current: usage.actionsMinutes,
|
|
472
|
+
limit: actionsLimit,
|
|
473
|
+
unit: 'minutes',
|
|
474
|
+
percentage: Math.round(actionsPct * 10) / 10,
|
|
475
|
+
costEstimate: actionsOverage * 0.008, // Overage at $0.008/min
|
|
476
|
+
status: getServiceUtilizationStatus(actionsPct),
|
|
477
|
+
overage: actionsOverage,
|
|
478
|
+
overageCost: actionsOverage * 0.008,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Actions Storage
|
|
482
|
+
const storageLimit = usage.actionsStorageGbIncluded || 50;
|
|
483
|
+
const storageGb = usage.actionsStorageGbHours / 24; // Convert to GB (approx)
|
|
484
|
+
const storagePct = storageLimit > 0 ? (storageGb / storageLimit) * 100 : 0;
|
|
485
|
+
const storageOverage = Math.max(0, storageGb - storageLimit);
|
|
486
|
+
metrics.push({
|
|
487
|
+
id: 'gh-actions-storage',
|
|
488
|
+
label: 'Actions Storage',
|
|
489
|
+
provider: 'github',
|
|
490
|
+
current: Math.round(storageGb * 100) / 100,
|
|
491
|
+
limit: storageLimit,
|
|
492
|
+
unit: 'GB',
|
|
493
|
+
percentage: Math.round(storagePct * 10) / 10,
|
|
494
|
+
costEstimate: storageOverage * 0.25, // Overage at $0.25/GB
|
|
495
|
+
status: getServiceUtilizationStatus(storagePct),
|
|
496
|
+
overage: storageOverage,
|
|
497
|
+
overageCost: storageOverage * 0.25,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// GHAS Code Security (subscription, not utilization-based)
|
|
501
|
+
if (usage.ghasCodeSecuritySeats > 0) {
|
|
502
|
+
metrics.push({
|
|
503
|
+
id: 'gh-ghas-code',
|
|
504
|
+
label: 'GHAS Code Security',
|
|
505
|
+
provider: 'github',
|
|
506
|
+
current: usage.ghasCodeSecuritySeats,
|
|
507
|
+
limit: null, // Subscription-based
|
|
508
|
+
unit: 'seats',
|
|
509
|
+
percentage: 100, // Fixed subscription
|
|
510
|
+
costEstimate: usage.ghasCodeSecuritySeats * 49,
|
|
511
|
+
status: 'ok',
|
|
512
|
+
overage: 0,
|
|
513
|
+
overageCost: 0,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// GHAS Secret Protection
|
|
518
|
+
if (usage.ghasSecretProtectionSeats > 0) {
|
|
519
|
+
metrics.push({
|
|
520
|
+
id: 'gh-ghas-secrets',
|
|
521
|
+
label: 'GHAS Secret Protection',
|
|
522
|
+
provider: 'github',
|
|
523
|
+
current: usage.ghasSecretProtectionSeats,
|
|
524
|
+
limit: null, // Subscription-based
|
|
525
|
+
unit: 'seats',
|
|
526
|
+
percentage: 100,
|
|
527
|
+
costEstimate: usage.ghasSecretProtectionSeats * 31,
|
|
528
|
+
status: 'ok',
|
|
529
|
+
overage: 0,
|
|
530
|
+
overageCost: 0,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// GHEC Users
|
|
535
|
+
if (usage.ghecUserMonths > 0) {
|
|
536
|
+
metrics.push({
|
|
537
|
+
id: 'gh-ghec',
|
|
538
|
+
label: 'GHEC Seats',
|
|
539
|
+
provider: 'github',
|
|
540
|
+
current: usage.ghecUserMonths,
|
|
541
|
+
limit: null, // Subscription-based
|
|
542
|
+
unit: 'users',
|
|
543
|
+
percentage: 100,
|
|
544
|
+
costEstimate: usage.ghecUserMonths * 21,
|
|
545
|
+
status: 'ok',
|
|
546
|
+
overage: 0,
|
|
547
|
+
overageCost: 0,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return metrics;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Calculate provider health summary from service metrics.
|
|
556
|
+
*/
|
|
557
|
+
function calculateProviderHealth(
|
|
558
|
+
metrics: ResourceMetricData[],
|
|
559
|
+
provider: 'cloudflare' | 'github'
|
|
560
|
+
): ProviderHealthData {
|
|
561
|
+
const utilizationMetrics = metrics.filter((m) => m.limit !== null && m.limit > 0);
|
|
562
|
+
|
|
563
|
+
if (utilizationMetrics.length === 0) {
|
|
564
|
+
return { provider, percentage: 0, warnings: 0, status: 'ok' };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Calculate weighted average by cost, or simple max
|
|
568
|
+
const maxPct = Math.max(...utilizationMetrics.map((m) => m.percentage));
|
|
569
|
+
const warnings = utilizationMetrics.filter(
|
|
570
|
+
(m) => m.status === 'warning' || m.status === 'critical' || m.status === 'overage'
|
|
571
|
+
).length;
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
provider,
|
|
575
|
+
percentage: Math.round(maxPct),
|
|
576
|
+
warnings,
|
|
577
|
+
status: getServiceUtilizationStatus(maxPct),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// =============================================================================
|
|
582
|
+
// HANDLER FUNCTIONS
|
|
583
|
+
// =============================================================================
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Handle GET /usage
|
|
587
|
+
*
|
|
588
|
+
* Primary data source: D1 (hourly/daily rollups)
|
|
589
|
+
* Fallback: Live GraphQL API if D1 data is missing
|
|
590
|
+
* Added: Projected monthly burn calculation
|
|
591
|
+
*/
|
|
592
|
+
export async function handleUsage(url: URL, env: Env): Promise<Response> {
|
|
593
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:handle');
|
|
594
|
+
const startTime = Date.now();
|
|
595
|
+
const { period, project } = await parseQueryParamsWithRegistry(url, env);
|
|
596
|
+
const cacheKey = getCacheKey('usage', period, project);
|
|
597
|
+
|
|
598
|
+
// Build project lookup cache for filtering (used in GraphQL fallback)
|
|
599
|
+
const projectLookupCache = await buildProjectLookupCache(env);
|
|
600
|
+
|
|
601
|
+
// Check KV cache first
|
|
602
|
+
try {
|
|
603
|
+
const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as
|
|
604
|
+
| (UsageResponse & { dataSource?: string; projectedBurn?: ProjectedBurn })
|
|
605
|
+
| null;
|
|
606
|
+
if (cached) {
|
|
607
|
+
log.info('Cache hit', { tag: 'USAGE', cacheKey });
|
|
608
|
+
return jsonResponse({
|
|
609
|
+
...cached,
|
|
610
|
+
cached: true,
|
|
611
|
+
responseTimeMs: Date.now() - startTime,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
} catch (error) {
|
|
615
|
+
log.error('Cache read error', error as Error, { tag: 'USAGE', cacheKey });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
log.info('Cache miss, fetching fresh data', { tag: 'USAGE', cacheKey });
|
|
619
|
+
|
|
620
|
+
// Try D1 first as primary data source
|
|
621
|
+
const d1Data = await queryD1UsageData(env, period, project);
|
|
622
|
+
const projectedBurn = await calculateProjectedBurn(env, project);
|
|
623
|
+
|
|
624
|
+
if (d1Data && d1Data.rowCount > 0) {
|
|
625
|
+
// D1 has data - use it as primary source
|
|
626
|
+
log.info('Using D1 data source', { tag: 'USAGE', rowCount: d1Data.rowCount });
|
|
627
|
+
|
|
628
|
+
const costs = d1Data.costs;
|
|
629
|
+
const response = {
|
|
630
|
+
success: true,
|
|
631
|
+
period,
|
|
632
|
+
project,
|
|
633
|
+
timestamp: new Date().toISOString(),
|
|
634
|
+
cached: false,
|
|
635
|
+
dataSource: 'd1' as const,
|
|
636
|
+
data: {
|
|
637
|
+
// D1 doesn't store per-resource details, provide summary only
|
|
638
|
+
workers: [] as AccountUsage['workers'],
|
|
639
|
+
d1: [] as AccountUsage['d1'],
|
|
640
|
+
kv: [] as AccountUsage['kv'],
|
|
641
|
+
r2: [] as AccountUsage['r2'],
|
|
642
|
+
durableObjects: {
|
|
643
|
+
requests: 0,
|
|
644
|
+
responseBodySize: 0,
|
|
645
|
+
gbSeconds: 0,
|
|
646
|
+
storageReadUnits: 0,
|
|
647
|
+
storageWriteUnits: 0,
|
|
648
|
+
storageDeleteUnits: 0,
|
|
649
|
+
} as AccountUsage['durableObjects'],
|
|
650
|
+
vectorize: [] as AccountUsage['vectorize'],
|
|
651
|
+
aiGateway: [] as AccountUsage['aiGateway'],
|
|
652
|
+
pages: [] as AccountUsage['pages'],
|
|
653
|
+
summary: {
|
|
654
|
+
totalWorkers: 0,
|
|
655
|
+
totalD1Databases: 0,
|
|
656
|
+
totalKVNamespaces: 0,
|
|
657
|
+
totalR2Buckets: 0,
|
|
658
|
+
totalVectorizeIndexes: 0,
|
|
659
|
+
totalAIGateways: 0,
|
|
660
|
+
totalPagesProjects: 0,
|
|
661
|
+
totalRequests: 0,
|
|
662
|
+
totalRowsRead: 0,
|
|
663
|
+
totalRowsWritten: 0,
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
costs: {
|
|
667
|
+
...costs,
|
|
668
|
+
formatted: {
|
|
669
|
+
workers: formatCurrency(costs.workers),
|
|
670
|
+
d1: formatCurrency(costs.d1),
|
|
671
|
+
kv: formatCurrency(costs.kv),
|
|
672
|
+
r2: formatCurrency(costs.r2),
|
|
673
|
+
durableObjects: formatCurrency(costs.durableObjects),
|
|
674
|
+
vectorize: formatCurrency(costs.vectorize),
|
|
675
|
+
aiGateway: formatCurrency(costs.aiGateway),
|
|
676
|
+
pages: formatCurrency(costs.pages),
|
|
677
|
+
queues: formatCurrency(costs.queues),
|
|
678
|
+
workersAI: formatCurrency(costs.workersAI),
|
|
679
|
+
total: formatCurrency(costs.total),
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
projectCosts: [] as ProjectCostBreakdown[],
|
|
683
|
+
thresholds: {
|
|
684
|
+
workers: { level: 'normal' as const, percentage: 0 },
|
|
685
|
+
d1: { level: 'normal' as const, percentage: 0 },
|
|
686
|
+
kv: { level: 'normal' as const, percentage: 0 },
|
|
687
|
+
r2: { level: 'normal' as const, percentage: 0 },
|
|
688
|
+
durableObjects: { level: 'normal' as const, percentage: 0 },
|
|
689
|
+
vectorize: { level: 'normal' as const, percentage: 0 },
|
|
690
|
+
aiGateway: { level: 'normal' as const, percentage: 0 },
|
|
691
|
+
pages: { level: 'normal' as const, percentage: 0 },
|
|
692
|
+
},
|
|
693
|
+
projectedBurn,
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// Cache the response in KV (1hr TTL)
|
|
697
|
+
try {
|
|
698
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
|
|
699
|
+
log.info('Cached D1 response', { tag: 'USAGE', cacheKey });
|
|
700
|
+
} catch (error) {
|
|
701
|
+
log.error('Cache write error', error as Error, { tag: 'USAGE', cacheKey });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const duration = Date.now() - startTime;
|
|
705
|
+
log.info('Fetched D1 data', { tag: 'USAGE', durationMs: duration });
|
|
706
|
+
|
|
707
|
+
return jsonResponse({
|
|
708
|
+
...response,
|
|
709
|
+
responseTimeMs: duration,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Fallback to live GraphQL if D1 is empty
|
|
714
|
+
log.info('D1 empty, falling back to GraphQL', { tag: 'USAGE' });
|
|
715
|
+
|
|
716
|
+
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
|
|
717
|
+
return jsonResponse(
|
|
718
|
+
{
|
|
719
|
+
success: false,
|
|
720
|
+
error: 'Configuration Error',
|
|
721
|
+
message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
|
|
722
|
+
},
|
|
723
|
+
500
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const client = new CloudflareGraphQL(env);
|
|
729
|
+
const allMetrics = await client.getAllMetrics(period);
|
|
730
|
+
// Use D1 registry-backed filtering with pattern fallback
|
|
731
|
+
const filteredMetrics = filterByProjectWithRegistry(allMetrics, project, projectLookupCache);
|
|
732
|
+
const costs = calculateMonthlyCosts(filteredMetrics);
|
|
733
|
+
const projectCosts = calculateProjectCosts(allMetrics);
|
|
734
|
+
const thresholds = analyseThresholds(allMetrics);
|
|
735
|
+
|
|
736
|
+
const response = {
|
|
737
|
+
success: true,
|
|
738
|
+
period,
|
|
739
|
+
project,
|
|
740
|
+
timestamp: new Date().toISOString(),
|
|
741
|
+
cached: false,
|
|
742
|
+
dataSource: 'graphql' as const,
|
|
743
|
+
data: {
|
|
744
|
+
workers: filteredMetrics.workers,
|
|
745
|
+
d1: filteredMetrics.d1,
|
|
746
|
+
kv: filteredMetrics.kv,
|
|
747
|
+
r2: filteredMetrics.r2,
|
|
748
|
+
durableObjects: filteredMetrics.durableObjects,
|
|
749
|
+
vectorize: filteredMetrics.vectorize,
|
|
750
|
+
aiGateway: filteredMetrics.aiGateway,
|
|
751
|
+
pages: filteredMetrics.pages,
|
|
752
|
+
summary: calculateSummary(filteredMetrics),
|
|
753
|
+
},
|
|
754
|
+
costs: {
|
|
755
|
+
...costs,
|
|
756
|
+
formatted: {
|
|
757
|
+
workers: formatCurrency(costs.workers),
|
|
758
|
+
d1: formatCurrency(costs.d1),
|
|
759
|
+
kv: formatCurrency(costs.kv),
|
|
760
|
+
r2: formatCurrency(costs.r2),
|
|
761
|
+
durableObjects: formatCurrency(costs.durableObjects),
|
|
762
|
+
vectorize: formatCurrency(costs.vectorize),
|
|
763
|
+
aiGateway: formatCurrency(costs.aiGateway),
|
|
764
|
+
pages: formatCurrency(costs.pages),
|
|
765
|
+
queues: formatCurrency(costs.queues),
|
|
766
|
+
workflows: formatCurrency(costs.workflows),
|
|
767
|
+
total: formatCurrency(costs.total),
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
projectCosts,
|
|
771
|
+
thresholds,
|
|
772
|
+
projectedBurn,
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
// Cache the response in KV (1hr TTL)
|
|
776
|
+
try {
|
|
777
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
|
|
778
|
+
log.info('Cached GraphQL response', { tag: 'USAGE', cacheKey });
|
|
779
|
+
} catch (error) {
|
|
780
|
+
log.error('Cache write error', error as Error, { tag: 'USAGE', cacheKey });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const duration = Date.now() - startTime;
|
|
784
|
+
log.info('Fetched GraphQL data', { tag: 'USAGE', durationMs: duration });
|
|
785
|
+
|
|
786
|
+
return jsonResponse({
|
|
787
|
+
...response,
|
|
788
|
+
responseTimeMs: duration,
|
|
789
|
+
});
|
|
790
|
+
} catch (error) {
|
|
791
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
792
|
+
log.error('Error fetching usage data', error as Error, { tag: 'USAGE' });
|
|
793
|
+
|
|
794
|
+
return jsonResponse(
|
|
795
|
+
{
|
|
796
|
+
success: false,
|
|
797
|
+
error: 'Failed to fetch usage data',
|
|
798
|
+
message: errorMessage,
|
|
799
|
+
},
|
|
800
|
+
500
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Handle GET /usage/costs
|
|
807
|
+
*/
|
|
808
|
+
export async function handleCosts(url: URL, env: Env): Promise<Response> {
|
|
809
|
+
const startTime = Date.now();
|
|
810
|
+
const { period } = parseQueryParams(url);
|
|
811
|
+
const cacheKey = getCacheKey('costs', period, 'all');
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as Record<
|
|
815
|
+
string,
|
|
816
|
+
unknown
|
|
817
|
+
> | null;
|
|
818
|
+
if (cached) {
|
|
819
|
+
return jsonResponse({
|
|
820
|
+
success: true,
|
|
821
|
+
cached: true,
|
|
822
|
+
...cached,
|
|
823
|
+
responseTimeMs: Date.now() - startTime,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
} catch {
|
|
827
|
+
// Continue with fresh fetch
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
|
|
831
|
+
return jsonResponse(
|
|
832
|
+
{
|
|
833
|
+
success: false,
|
|
834
|
+
error: 'Configuration Error',
|
|
835
|
+
message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
|
|
836
|
+
},
|
|
837
|
+
500
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
const client = new CloudflareGraphQL(env);
|
|
843
|
+
const metrics = await client.getAllMetrics(period);
|
|
844
|
+
const costs = calculateMonthlyCosts(metrics);
|
|
845
|
+
const projectCosts = calculateProjectCosts(metrics);
|
|
846
|
+
|
|
847
|
+
const response = {
|
|
848
|
+
period,
|
|
849
|
+
timestamp: new Date().toISOString(),
|
|
850
|
+
costs: {
|
|
851
|
+
...costs,
|
|
852
|
+
formatted: {
|
|
853
|
+
workers: formatCurrency(costs.workers),
|
|
854
|
+
d1: formatCurrency(costs.d1),
|
|
855
|
+
kv: formatCurrency(costs.kv),
|
|
856
|
+
r2: formatCurrency(costs.r2),
|
|
857
|
+
durableObjects: formatCurrency(costs.durableObjects),
|
|
858
|
+
vectorize: formatCurrency(costs.vectorize),
|
|
859
|
+
aiGateway: formatCurrency(costs.aiGateway),
|
|
860
|
+
pages: formatCurrency(costs.pages),
|
|
861
|
+
queues: formatCurrency(costs.queues),
|
|
862
|
+
workflows: formatCurrency(costs.workflows),
|
|
863
|
+
total: formatCurrency(costs.total),
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
projectCosts,
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
|
|
871
|
+
} catch {
|
|
872
|
+
// Continue without caching
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return jsonResponse({
|
|
876
|
+
success: true,
|
|
877
|
+
cached: false,
|
|
878
|
+
...response,
|
|
879
|
+
responseTimeMs: Date.now() - startTime,
|
|
880
|
+
});
|
|
881
|
+
} catch (error) {
|
|
882
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
883
|
+
return jsonResponse(
|
|
884
|
+
{
|
|
885
|
+
success: false,
|
|
886
|
+
error: 'Failed to fetch cost data',
|
|
887
|
+
message: errorMessage,
|
|
888
|
+
},
|
|
889
|
+
500
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Handle GET /usage/thresholds
|
|
896
|
+
*/
|
|
897
|
+
export async function handleThresholds(url: URL, env: Env): Promise<Response> {
|
|
898
|
+
const startTime = Date.now();
|
|
899
|
+
const { period } = parseQueryParams(url);
|
|
900
|
+
|
|
901
|
+
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
|
|
902
|
+
return jsonResponse(
|
|
903
|
+
{
|
|
904
|
+
success: false,
|
|
905
|
+
error: 'Configuration Error',
|
|
906
|
+
message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
|
|
907
|
+
},
|
|
908
|
+
500
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
const client = new CloudflareGraphQL(env);
|
|
914
|
+
const metrics = await client.getAllMetrics(period);
|
|
915
|
+
const thresholds = analyseThresholds(metrics);
|
|
916
|
+
|
|
917
|
+
return jsonResponse({
|
|
918
|
+
success: true,
|
|
919
|
+
period,
|
|
920
|
+
timestamp: new Date().toISOString(),
|
|
921
|
+
thresholds,
|
|
922
|
+
responseTimeMs: Date.now() - startTime,
|
|
923
|
+
});
|
|
924
|
+
} catch (error) {
|
|
925
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
926
|
+
return jsonResponse(
|
|
927
|
+
{
|
|
928
|
+
success: false,
|
|
929
|
+
error: 'Failed to analyse thresholds',
|
|
930
|
+
message: errorMessage,
|
|
931
|
+
},
|
|
932
|
+
500
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Handle GET /usage/enhanced
|
|
939
|
+
*/
|
|
940
|
+
export async function handleEnhanced(url: URL, env: Env): Promise<Response> {
|
|
941
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:enhanced');
|
|
942
|
+
const startTime = Date.now();
|
|
943
|
+
const { period, project } = await parseQueryParamsWithRegistry(url, env);
|
|
944
|
+
const cacheKey = getCacheKey('enhanced', period, project);
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as EnhancedUsageResponse | null;
|
|
948
|
+
if (cached) {
|
|
949
|
+
log.info('Enhanced cache hit', { tag: 'USAGE', cacheKey });
|
|
950
|
+
return jsonResponse({
|
|
951
|
+
...cached,
|
|
952
|
+
cached: true,
|
|
953
|
+
responseTimeMs: Date.now() - startTime,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
} catch (error) {
|
|
957
|
+
log.error('Enhanced cache read error', error as Error, { tag: 'USAGE', cacheKey });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
log.info('Enhanced cache miss, fetching fresh data', { tag: 'USAGE', cacheKey });
|
|
961
|
+
|
|
962
|
+
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
|
|
963
|
+
return jsonResponse(
|
|
964
|
+
{
|
|
965
|
+
success: false,
|
|
966
|
+
error: 'Configuration Error',
|
|
967
|
+
message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
|
|
968
|
+
},
|
|
969
|
+
500
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Build project lookup cache for D1 registry-backed filtering
|
|
974
|
+
const projectLookupCache = await buildProjectLookupCache(env);
|
|
975
|
+
|
|
976
|
+
try {
|
|
977
|
+
const client = new CloudflareGraphQL(env);
|
|
978
|
+
const enhancedMetrics = await client.getAllEnhancedMetrics(period);
|
|
979
|
+
const filteredMetrics = filterByProjectWithRegistry(
|
|
980
|
+
enhancedMetrics,
|
|
981
|
+
project,
|
|
982
|
+
projectLookupCache
|
|
983
|
+
);
|
|
984
|
+
const costs = calculateMonthlyCosts(filteredMetrics);
|
|
985
|
+
const projectCosts = calculateProjectCosts(enhancedMetrics);
|
|
986
|
+
const thresholds = analyseThresholds(enhancedMetrics);
|
|
987
|
+
const totalErrors = filteredMetrics.workers.reduce((sum, w) => sum + w.errors, 0);
|
|
988
|
+
|
|
989
|
+
const response: EnhancedUsageResponse = {
|
|
990
|
+
success: true,
|
|
991
|
+
period,
|
|
992
|
+
project,
|
|
993
|
+
timestamp: new Date().toISOString(),
|
|
994
|
+
cached: false,
|
|
995
|
+
data: {
|
|
996
|
+
workers: filteredMetrics.workers,
|
|
997
|
+
d1: filteredMetrics.d1,
|
|
998
|
+
kv: filteredMetrics.kv,
|
|
999
|
+
r2: filteredMetrics.r2,
|
|
1000
|
+
durableObjects: filteredMetrics.durableObjects,
|
|
1001
|
+
vectorize: filteredMetrics.vectorize,
|
|
1002
|
+
aiGateway: filteredMetrics.aiGateway,
|
|
1003
|
+
pages: filteredMetrics.pages,
|
|
1004
|
+
summary: {
|
|
1005
|
+
...calculateSummary(filteredMetrics),
|
|
1006
|
+
totalErrors,
|
|
1007
|
+
} as UsageResponse['data']['summary'] & { totalErrors: number },
|
|
1008
|
+
},
|
|
1009
|
+
sparklines: enhancedMetrics.sparklines,
|
|
1010
|
+
errorBreakdown: enhancedMetrics.errorBreakdown,
|
|
1011
|
+
queues: enhancedMetrics.queues,
|
|
1012
|
+
cache: enhancedMetrics.cache,
|
|
1013
|
+
comparison: enhancedMetrics.comparison,
|
|
1014
|
+
costs: {
|
|
1015
|
+
...costs,
|
|
1016
|
+
formatted: {
|
|
1017
|
+
workers: formatCurrency(costs.workers),
|
|
1018
|
+
d1: formatCurrency(costs.d1),
|
|
1019
|
+
kv: formatCurrency(costs.kv),
|
|
1020
|
+
r2: formatCurrency(costs.r2),
|
|
1021
|
+
durableObjects: formatCurrency(costs.durableObjects),
|
|
1022
|
+
vectorize: formatCurrency(costs.vectorize),
|
|
1023
|
+
aiGateway: formatCurrency(costs.aiGateway),
|
|
1024
|
+
pages: formatCurrency(costs.pages),
|
|
1025
|
+
queues: formatCurrency(costs.queues),
|
|
1026
|
+
workflows: formatCurrency(costs.workflows),
|
|
1027
|
+
total: formatCurrency(costs.total),
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
projectCosts,
|
|
1031
|
+
thresholds,
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
try {
|
|
1035
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
|
|
1036
|
+
log.info('Enhanced cached response', { tag: 'USAGE', cacheKey });
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
log.error('Enhanced cache write error', error as Error, { tag: 'USAGE', cacheKey });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const duration = Date.now() - startTime;
|
|
1042
|
+
log.info('Enhanced usage data fetched', { tag: 'USAGE', durationMs: duration });
|
|
1043
|
+
|
|
1044
|
+
return jsonResponse({
|
|
1045
|
+
...response,
|
|
1046
|
+
responseTimeMs: duration,
|
|
1047
|
+
});
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1050
|
+
log.error('Enhanced error fetching usage data', error as Error, { tag: 'USAGE' });
|
|
1051
|
+
|
|
1052
|
+
return jsonResponse(
|
|
1053
|
+
{
|
|
1054
|
+
success: false,
|
|
1055
|
+
error: 'Failed to fetch enhanced usage data',
|
|
1056
|
+
message: errorMessage,
|
|
1057
|
+
},
|
|
1058
|
+
500
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Handle GET /usage/compare (task-17.3, 17.4)
|
|
1065
|
+
*
|
|
1066
|
+
* Query params:
|
|
1067
|
+
* - compare: 'lastMonth' | 'custom' (required)
|
|
1068
|
+
* - period: '24h' | '7d' | '30d' (for compare=lastMonth)
|
|
1069
|
+
* - startDate, endDate: YYYY-MM-DD (for compare=custom)
|
|
1070
|
+
* - priorStartDate, priorEndDate: YYYY-MM-DD (optional, for compare=custom)
|
|
1071
|
+
* - project: 'all' | <your-project-ids> (from project_registry)
|
|
1072
|
+
*/
|
|
1073
|
+
export async function handleCompare(url: URL, env: Env): Promise<Response> {
|
|
1074
|
+
const startTime = Date.now();
|
|
1075
|
+
const compareParam = url.searchParams.get('compare') as CompareMode | null;
|
|
1076
|
+
const { period, project } = parseQueryParams(url);
|
|
1077
|
+
|
|
1078
|
+
// Validate compare mode
|
|
1079
|
+
if (!compareParam || (compareParam !== 'lastMonth' && compareParam !== 'custom')) {
|
|
1080
|
+
return jsonResponse(
|
|
1081
|
+
{
|
|
1082
|
+
success: false,
|
|
1083
|
+
error: 'Invalid compare mode',
|
|
1084
|
+
message: "compare parameter must be 'lastMonth' or 'custom'",
|
|
1085
|
+
},
|
|
1086
|
+
400
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (!env.CLOUDFLARE_ACCOUNT_ID || !env.CLOUDFLARE_API_TOKEN) {
|
|
1091
|
+
return jsonResponse(
|
|
1092
|
+
{
|
|
1093
|
+
success: false,
|
|
1094
|
+
error: 'Configuration Error',
|
|
1095
|
+
message: 'Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN',
|
|
1096
|
+
},
|
|
1097
|
+
500
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
try {
|
|
1102
|
+
const client = new CloudflareGraphQL(env);
|
|
1103
|
+
let currentRange: DateRange;
|
|
1104
|
+
let priorRange: DateRange;
|
|
1105
|
+
|
|
1106
|
+
if (compareParam === 'lastMonth') {
|
|
1107
|
+
// Use period to determine date range, then get same period last month
|
|
1108
|
+
const now = new Date();
|
|
1109
|
+
const endDate = now.toISOString().split('T')[0]!;
|
|
1110
|
+
|
|
1111
|
+
const startDate = new Date(now);
|
|
1112
|
+
switch (period) {
|
|
1113
|
+
case '24h':
|
|
1114
|
+
startDate.setDate(startDate.getDate() - 1);
|
|
1115
|
+
break;
|
|
1116
|
+
case '7d':
|
|
1117
|
+
startDate.setDate(startDate.getDate() - 7);
|
|
1118
|
+
break;
|
|
1119
|
+
case '30d':
|
|
1120
|
+
startDate.setDate(startDate.getDate() - 30);
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
currentRange = {
|
|
1125
|
+
startDate: startDate.toISOString().split('T')[0]!,
|
|
1126
|
+
endDate,
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
priorRange = CloudflareGraphQL.getSamePeriodLastMonth(
|
|
1130
|
+
currentRange.startDate,
|
|
1131
|
+
currentRange.endDate
|
|
1132
|
+
);
|
|
1133
|
+
} else {
|
|
1134
|
+
// Custom date range
|
|
1135
|
+
const startDate = url.searchParams.get('startDate');
|
|
1136
|
+
const endDate = url.searchParams.get('endDate');
|
|
1137
|
+
const priorStartDate = url.searchParams.get('priorStartDate') ?? undefined;
|
|
1138
|
+
const priorEndDate = url.searchParams.get('priorEndDate') ?? undefined;
|
|
1139
|
+
|
|
1140
|
+
if (!startDate || !endDate) {
|
|
1141
|
+
return jsonResponse(
|
|
1142
|
+
{
|
|
1143
|
+
success: false,
|
|
1144
|
+
error: 'Missing date parameters',
|
|
1145
|
+
message: 'startDate and endDate are required for compare=custom',
|
|
1146
|
+
},
|
|
1147
|
+
400
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const validation = CloudflareGraphQL.validateCustomDateRange({
|
|
1152
|
+
startDate,
|
|
1153
|
+
endDate,
|
|
1154
|
+
priorStartDate,
|
|
1155
|
+
priorEndDate,
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
if ('error' in validation) {
|
|
1159
|
+
return jsonResponse(
|
|
1160
|
+
{
|
|
1161
|
+
success: false,
|
|
1162
|
+
error: 'Invalid date range',
|
|
1163
|
+
message: validation.error,
|
|
1164
|
+
},
|
|
1165
|
+
400
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
currentRange = validation.current;
|
|
1170
|
+
priorRange = validation.prior;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Fetch metrics for both periods in parallel
|
|
1174
|
+
const [currentMetricsRaw, priorMetricsRaw] = await Promise.all([
|
|
1175
|
+
client.getMetricsForDateRange(currentRange),
|
|
1176
|
+
client.getMetricsForDateRange(priorRange),
|
|
1177
|
+
]);
|
|
1178
|
+
|
|
1179
|
+
// Filter by project
|
|
1180
|
+
const currentMetrics = filterByProject(currentMetricsRaw, project);
|
|
1181
|
+
const priorMetrics = filterByProject(priorMetricsRaw, project);
|
|
1182
|
+
|
|
1183
|
+
// Calculate costs and summaries
|
|
1184
|
+
const currentCosts = calculateMonthlyCosts(currentMetrics);
|
|
1185
|
+
const priorCosts = calculateMonthlyCosts(priorMetrics);
|
|
1186
|
+
const currentSummary = calculateSummary(currentMetrics);
|
|
1187
|
+
const priorSummary = calculateSummary(priorMetrics);
|
|
1188
|
+
|
|
1189
|
+
// Calculate comparisons
|
|
1190
|
+
const currentRequests = currentMetrics.workers.reduce((s, w) => s + w.requests, 0);
|
|
1191
|
+
const priorRequests = priorMetrics.workers.reduce((s, w) => s + w.requests, 0);
|
|
1192
|
+
const currentErrors = currentMetrics.workers.reduce((s, w) => s + w.errors, 0);
|
|
1193
|
+
const priorErrors = priorMetrics.workers.reduce((s, w) => s + w.errors, 0);
|
|
1194
|
+
const currentD1Rows = currentMetrics.d1.reduce((s, d) => s + d.rowsRead, 0);
|
|
1195
|
+
const priorD1Rows = priorMetrics.d1.reduce((s, d) => s + d.rowsRead, 0);
|
|
1196
|
+
|
|
1197
|
+
const response: ComparisonResponse = {
|
|
1198
|
+
success: true,
|
|
1199
|
+
compareMode: compareParam,
|
|
1200
|
+
current: {
|
|
1201
|
+
dateRange: currentRange,
|
|
1202
|
+
summary: currentSummary,
|
|
1203
|
+
costs: currentCosts,
|
|
1204
|
+
data: currentMetrics,
|
|
1205
|
+
},
|
|
1206
|
+
prior: {
|
|
1207
|
+
dateRange: priorRange,
|
|
1208
|
+
summary: priorSummary,
|
|
1209
|
+
costs: priorCosts,
|
|
1210
|
+
data: priorMetrics,
|
|
1211
|
+
},
|
|
1212
|
+
comparison: {
|
|
1213
|
+
workersRequests: {
|
|
1214
|
+
current: currentRequests,
|
|
1215
|
+
previous: priorRequests,
|
|
1216
|
+
...calcTrend(currentRequests, priorRequests),
|
|
1217
|
+
},
|
|
1218
|
+
workersErrors: {
|
|
1219
|
+
current: currentErrors,
|
|
1220
|
+
previous: priorErrors,
|
|
1221
|
+
...calcTrend(currentErrors, priorErrors),
|
|
1222
|
+
},
|
|
1223
|
+
d1RowsRead: {
|
|
1224
|
+
current: currentD1Rows,
|
|
1225
|
+
previous: priorD1Rows,
|
|
1226
|
+
...calcTrend(currentD1Rows, priorD1Rows),
|
|
1227
|
+
},
|
|
1228
|
+
totalCost: {
|
|
1229
|
+
current: currentCosts.total,
|
|
1230
|
+
previous: priorCosts.total,
|
|
1231
|
+
...calcTrend(currentCosts.total, priorCosts.total),
|
|
1232
|
+
},
|
|
1233
|
+
},
|
|
1234
|
+
timestamp: new Date().toISOString(),
|
|
1235
|
+
cached: false,
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const duration = Date.now() - startTime;
|
|
1239
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:compare');
|
|
1240
|
+
log.info('Comparison data fetched', { tag: 'COMPARE_FETCHED', durationMs: duration });
|
|
1241
|
+
|
|
1242
|
+
return jsonResponse({
|
|
1243
|
+
...response,
|
|
1244
|
+
responseTimeMs: duration,
|
|
1245
|
+
});
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1248
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:compare');
|
|
1249
|
+
log.error('Error fetching comparison data', error instanceof Error ? error : undefined, {
|
|
1250
|
+
tag: 'COMPARE_ERROR',
|
|
1251
|
+
errorMessage,
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
return jsonResponse(
|
|
1255
|
+
{
|
|
1256
|
+
success: false,
|
|
1257
|
+
error: 'Failed to fetch comparison data',
|
|
1258
|
+
message: errorMessage,
|
|
1259
|
+
},
|
|
1260
|
+
500
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Handle GET /usage/daily (task-18)
|
|
1267
|
+
*
|
|
1268
|
+
* Returns daily cost breakdown for interactive chart and table.
|
|
1269
|
+
* Supports period-based or custom date range queries.
|
|
1270
|
+
*
|
|
1271
|
+
* Query params:
|
|
1272
|
+
* - period: '24h' | '7d' | '30d' | 'custom' (default: '30d')
|
|
1273
|
+
* - startDate: YYYY-MM-DD (required for period=custom)
|
|
1274
|
+
* - endDate: YYYY-MM-DD (required for period=custom)
|
|
1275
|
+
*/
|
|
1276
|
+
export async function handleDaily(url: URL, env: Env): Promise<Response> {
|
|
1277
|
+
const startTime = Date.now();
|
|
1278
|
+
const periodParam = url.searchParams.get('period') ?? '30d';
|
|
1279
|
+
const startDateParam = url.searchParams.get('startDate');
|
|
1280
|
+
const endDateParam = url.searchParams.get('endDate');
|
|
1281
|
+
const projectParam = url.searchParams.get('project') ?? 'all';
|
|
1282
|
+
|
|
1283
|
+
// Build cache key (include project in key for per-project caching)
|
|
1284
|
+
let cacheKeyPart: string;
|
|
1285
|
+
if (periodParam === 'custom' && startDateParam && endDateParam) {
|
|
1286
|
+
cacheKeyPart = `custom:${startDateParam}:${endDateParam}`;
|
|
1287
|
+
} else {
|
|
1288
|
+
const validPeriods: SharedTimePeriod[] = ['24h', '7d', '30d'];
|
|
1289
|
+
const period: SharedTimePeriod = validPeriods.includes(periodParam as SharedTimePeriod)
|
|
1290
|
+
? (periodParam as SharedTimePeriod)
|
|
1291
|
+
: '30d';
|
|
1292
|
+
cacheKeyPart = period;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const hourTimestamp = Math.floor(Date.now() / (60 * 60 * 1000));
|
|
1296
|
+
const cacheKey = `daily:${projectParam}:${cacheKeyPart}:${hourTimestamp}`;
|
|
1297
|
+
|
|
1298
|
+
// Check for cache bypass parameter
|
|
1299
|
+
const noCache = url.searchParams.get('nocache') === 'true';
|
|
1300
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:daily');
|
|
1301
|
+
|
|
1302
|
+
// Check cache first (unless nocache=true)
|
|
1303
|
+
if (!noCache) {
|
|
1304
|
+
try {
|
|
1305
|
+
const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as DailyCostResponse | null;
|
|
1306
|
+
if (cached) {
|
|
1307
|
+
log.info('Daily cache hit', { tag: 'CACHE_HIT', cacheKey });
|
|
1308
|
+
return jsonResponse({
|
|
1309
|
+
...cached,
|
|
1310
|
+
cached: true,
|
|
1311
|
+
responseTimeMs: Date.now() - startTime,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
log.error('Daily cache read error', error instanceof Error ? error : undefined, {
|
|
1316
|
+
tag: 'CACHE_READ_ERROR',
|
|
1317
|
+
cacheKey,
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
} else {
|
|
1321
|
+
log.info('Daily cache bypassed', { tag: 'CACHE_BYPASS', cacheKey });
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
log.info('Daily cache miss, fetching fresh data', { tag: 'CACHE_MISS', cacheKey });
|
|
1325
|
+
|
|
1326
|
+
// Parse period for D1 query
|
|
1327
|
+
let d1Period: SharedTimePeriod | { start: string; end: string };
|
|
1328
|
+
let periodDisplay: string;
|
|
1329
|
+
|
|
1330
|
+
if (periodParam === 'custom' && startDateParam && endDateParam) {
|
|
1331
|
+
// Validate date range (max 90 days)
|
|
1332
|
+
const startDate = new Date(startDateParam);
|
|
1333
|
+
const endDate = new Date(endDateParam);
|
|
1334
|
+
const diffDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
1335
|
+
|
|
1336
|
+
if (diffDays > 90) {
|
|
1337
|
+
return jsonResponse(
|
|
1338
|
+
{
|
|
1339
|
+
success: false,
|
|
1340
|
+
error: 'Invalid date range',
|
|
1341
|
+
message: 'Date range cannot exceed 90 days',
|
|
1342
|
+
},
|
|
1343
|
+
400
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (diffDays < 1) {
|
|
1348
|
+
return jsonResponse(
|
|
1349
|
+
{
|
|
1350
|
+
success: false,
|
|
1351
|
+
error: 'Invalid date range',
|
|
1352
|
+
message: 'End date must be after start date',
|
|
1353
|
+
},
|
|
1354
|
+
400
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
d1Period = { start: startDateParam, end: endDateParam };
|
|
1359
|
+
periodDisplay = `${startDateParam} to ${endDateParam}`;
|
|
1360
|
+
} else {
|
|
1361
|
+
// Standard period-based query
|
|
1362
|
+
const validPeriods: SharedTimePeriod[] = ['24h', '7d', '30d'];
|
|
1363
|
+
d1Period = validPeriods.includes(periodParam as SharedTimePeriod)
|
|
1364
|
+
? (periodParam as SharedTimePeriod)
|
|
1365
|
+
: '30d';
|
|
1366
|
+
periodDisplay = periodParam;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
try {
|
|
1370
|
+
// Try D1 Data Warehouse first (pass project filter)
|
|
1371
|
+
const d1Data = await queryD1DailyCosts(env, d1Period, projectParam);
|
|
1372
|
+
|
|
1373
|
+
if (d1Data && d1Data.days.length > 0) {
|
|
1374
|
+
log.info('Daily data from D1', {
|
|
1375
|
+
tag: 'D1_DATA',
|
|
1376
|
+
dayCount: d1Data.days.length,
|
|
1377
|
+
project: projectParam,
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
const response: DailyCostResponse & { dataSource: string; project: string } = {
|
|
1381
|
+
success: true,
|
|
1382
|
+
period: periodDisplay,
|
|
1383
|
+
project: projectParam,
|
|
1384
|
+
dataSource: 'd1',
|
|
1385
|
+
data: d1Data,
|
|
1386
|
+
cached: false,
|
|
1387
|
+
timestamp: new Date().toISOString(),
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
// Cache for 1 hour
|
|
1391
|
+
try {
|
|
1392
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 1800 });
|
|
1393
|
+
log.info('Daily cached D1 response', { tag: 'CACHE_WRITE', cacheKey });
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
log.error('Daily cache write error', error instanceof Error ? error : undefined, {
|
|
1396
|
+
tag: 'CACHE_WRITE_ERROR',
|
|
1397
|
+
cacheKey,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const duration = Date.now() - startTime;
|
|
1402
|
+
log.info('Daily data from D1', { tag: 'D1_COMPLETE', durationMs: duration });
|
|
1403
|
+
|
|
1404
|
+
return jsonResponse({
|
|
1405
|
+
...response,
|
|
1406
|
+
responseTimeMs: duration,
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// D1 is empty - return no_data response (GraphQL fallback disabled)
|
|
1411
|
+
log.info('D1 empty - returning no_data response (GraphQL fallback disabled)', {
|
|
1412
|
+
tag: 'D1_EMPTY',
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
const emptyTotals: DailyCostData['totals'] = {
|
|
1416
|
+
workers: 0,
|
|
1417
|
+
d1: 0,
|
|
1418
|
+
kv: 0,
|
|
1419
|
+
r2: 0,
|
|
1420
|
+
durableObjects: 0,
|
|
1421
|
+
vectorize: 0,
|
|
1422
|
+
aiGateway: 0,
|
|
1423
|
+
workersAI: 0,
|
|
1424
|
+
pages: 0,
|
|
1425
|
+
queues: 0,
|
|
1426
|
+
workflows: 0,
|
|
1427
|
+
total: 0,
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
const emptyData: DailyCostData = {
|
|
1431
|
+
days: [],
|
|
1432
|
+
totals: emptyTotals,
|
|
1433
|
+
period: {
|
|
1434
|
+
start: typeof d1Period === 'object' ? d1Period.start : '',
|
|
1435
|
+
end: typeof d1Period === 'object' ? d1Period.end : '',
|
|
1436
|
+
},
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
const response: DailyCostResponse & {
|
|
1440
|
+
dataSource: string;
|
|
1441
|
+
dataAvailability: string;
|
|
1442
|
+
message: string;
|
|
1443
|
+
} = {
|
|
1444
|
+
success: true,
|
|
1445
|
+
period: periodDisplay,
|
|
1446
|
+
dataSource: 'none',
|
|
1447
|
+
dataAvailability: 'no_data',
|
|
1448
|
+
message:
|
|
1449
|
+
'Daily rollups not yet available. Data collection started recently and will populate after midnight UTC.',
|
|
1450
|
+
data: emptyData,
|
|
1451
|
+
cached: false,
|
|
1452
|
+
timestamp: new Date().toISOString(),
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
const duration = Date.now() - startTime;
|
|
1456
|
+
log.info('Returning empty daily data', { tag: 'EMPTY_RESPONSE', durationMs: duration });
|
|
1457
|
+
|
|
1458
|
+
return jsonResponse({
|
|
1459
|
+
...response,
|
|
1460
|
+
responseTimeMs: duration,
|
|
1461
|
+
});
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1464
|
+
log.error('Error fetching daily data', error instanceof Error ? error : undefined, {
|
|
1465
|
+
tag: 'DAILY_ERROR',
|
|
1466
|
+
errorMessage,
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
return jsonResponse(
|
|
1470
|
+
{
|
|
1471
|
+
success: false,
|
|
1472
|
+
error: 'Failed to fetch daily cost data',
|
|
1473
|
+
message: errorMessage,
|
|
1474
|
+
},
|
|
1475
|
+
500
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Handle GET /usage/status
|
|
1482
|
+
*
|
|
1483
|
+
* Returns project status data for the unified dashboard including:
|
|
1484
|
+
* - Circuit breaker state per project
|
|
1485
|
+
* - MTD spend and cap
|
|
1486
|
+
* - Usage percentage
|
|
1487
|
+
* - Operational status (RUN/WARN/STOP)
|
|
1488
|
+
*
|
|
1489
|
+
* Supports ?period parameter for different time ranges.
|
|
1490
|
+
*/
|
|
1491
|
+
export async function handleStatus(url: URL, env: Env): Promise<Response> {
|
|
1492
|
+
const startTime = Date.now();
|
|
1493
|
+
|
|
1494
|
+
try {
|
|
1495
|
+
// Get period param (default 30d for MTD)
|
|
1496
|
+
const period = url.searchParams.get('period') || '30d';
|
|
1497
|
+
|
|
1498
|
+
// Get projects from D1 registry
|
|
1499
|
+
const allProjects = await getProjects(env.PLATFORM_DB);
|
|
1500
|
+
const projectsWithConfig = allProjects
|
|
1501
|
+
.map((p: Project) => ({ project: p, config: getProjectConfig(p) }))
|
|
1502
|
+
.filter(
|
|
1503
|
+
(p: {
|
|
1504
|
+
project: Project;
|
|
1505
|
+
config: ReturnType<typeof getProjectConfig>;
|
|
1506
|
+
}): p is { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> } =>
|
|
1507
|
+
p.config !== null
|
|
1508
|
+
);
|
|
1509
|
+
|
|
1510
|
+
// Get date range based on period
|
|
1511
|
+
const now = new Date();
|
|
1512
|
+
const currentYear = now.getUTCFullYear();
|
|
1513
|
+
const currentMonth = now.getUTCMonth();
|
|
1514
|
+
const mtdStartDate = new Date(Date.UTC(currentYear, currentMonth, 1))
|
|
1515
|
+
.toISOString()
|
|
1516
|
+
.slice(0, 10);
|
|
1517
|
+
const mtdEndDate = now.toISOString().slice(0, 10);
|
|
1518
|
+
|
|
1519
|
+
// Query per-project MTD costs
|
|
1520
|
+
const projectIds = projectsWithConfig.map(
|
|
1521
|
+
(p: { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> }) =>
|
|
1522
|
+
p.project.projectId
|
|
1523
|
+
);
|
|
1524
|
+
const projectCostPromises = projectIds.map(async (projectId: string) => {
|
|
1525
|
+
const projectData = await queryD1DailyCosts(
|
|
1526
|
+
env,
|
|
1527
|
+
{ start: mtdStartDate, end: mtdEndDate },
|
|
1528
|
+
projectId
|
|
1529
|
+
);
|
|
1530
|
+
return { projectId, mtdCost: projectData?.totals?.total ?? 0 };
|
|
1531
|
+
});
|
|
1532
|
+
const projectCostResults = await Promise.all(projectCostPromises);
|
|
1533
|
+
const perProjectCosts: Record<string, number> = {};
|
|
1534
|
+
for (const result of projectCostResults) {
|
|
1535
|
+
perProjectCosts[result.projectId] = result.mtdCost;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Get circuit breaker status from KV for all registered projects
|
|
1539
|
+
// TODO: Add your project IDs to project_registry in D1
|
|
1540
|
+
const cbStatuses: Record<string, string> = {};
|
|
1541
|
+
{
|
|
1542
|
+
const projectRows = await env.PLATFORM_DB.prepare(
|
|
1543
|
+
`SELECT project_id FROM project_registry WHERE project_id != 'all'`
|
|
1544
|
+
).all<{ project_id: string }>();
|
|
1545
|
+
const projectIds = projectRows.results?.map((r) => r.project_id) ?? ['platform'];
|
|
1546
|
+
const cbResults = await Promise.all(
|
|
1547
|
+
projectIds.map(async (pid) => {
|
|
1548
|
+
const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
|
|
1549
|
+
const status = await env.PLATFORM_CACHE.get(cbKey);
|
|
1550
|
+
return { pid, status };
|
|
1551
|
+
})
|
|
1552
|
+
);
|
|
1553
|
+
for (const { pid, status } of cbResults) {
|
|
1554
|
+
cbStatuses[pid] = status ?? 'active';
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Query feature_registry to find which projects have CB-enabled features
|
|
1559
|
+
const projectsWithCBEnabled = new Set<string>();
|
|
1560
|
+
try {
|
|
1561
|
+
const cbEnabledResult = await env.PLATFORM_DB.prepare(
|
|
1562
|
+
`
|
|
1563
|
+
SELECT DISTINCT project_id
|
|
1564
|
+
FROM feature_registry
|
|
1565
|
+
WHERE circuit_breaker_enabled = 1
|
|
1566
|
+
`
|
|
1567
|
+
).all();
|
|
1568
|
+
for (const row of cbEnabledResult.results ?? []) {
|
|
1569
|
+
projectsWithCBEnabled.add(row.project_id as string);
|
|
1570
|
+
}
|
|
1571
|
+
} catch {
|
|
1572
|
+
// Fallback: add all known projects from cbStatuses
|
|
1573
|
+
for (const pid of Object.keys(cbStatuses)) {
|
|
1574
|
+
projectsWithCBEnabled.add(pid);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Build project status map
|
|
1579
|
+
const projects: Record<
|
|
1580
|
+
string,
|
|
1581
|
+
{
|
|
1582
|
+
status: 'RUN' | 'WARN' | 'STOP';
|
|
1583
|
+
spend: number;
|
|
1584
|
+
cap: number;
|
|
1585
|
+
percentage: number;
|
|
1586
|
+
circuitBreaker: 'active' | 'tripped' | 'disabled';
|
|
1587
|
+
lastSeen?: string;
|
|
1588
|
+
}
|
|
1589
|
+
> = {};
|
|
1590
|
+
|
|
1591
|
+
// Get budget thresholds
|
|
1592
|
+
const { softBudgetLimit, warningThreshold } = await getBudgetThresholds(env);
|
|
1593
|
+
|
|
1594
|
+
for (const { project, config } of projectsWithConfig) {
|
|
1595
|
+
const projectId = project.projectId;
|
|
1596
|
+
const spend = perProjectCosts[projectId] ?? 0;
|
|
1597
|
+
|
|
1598
|
+
// Use project-specific cap if available, else account-level soft limit
|
|
1599
|
+
const cap = config.customLimit ?? softBudgetLimit;
|
|
1600
|
+
const percentage = cap > 0 ? (spend / cap) * 100 : 0;
|
|
1601
|
+
|
|
1602
|
+
// Get circuit breaker state
|
|
1603
|
+
const cbKvState = cbStatuses[projectId] ?? 'active';
|
|
1604
|
+
const hasCBEnabled = projectsWithCBEnabled.has(projectId);
|
|
1605
|
+
|
|
1606
|
+
let circuitBreaker: 'active' | 'tripped' | 'disabled' = 'disabled';
|
|
1607
|
+
if (hasCBEnabled) {
|
|
1608
|
+
// Map KV status values to CB state
|
|
1609
|
+
if (cbKvState === 'paused') {
|
|
1610
|
+
circuitBreaker = 'tripped';
|
|
1611
|
+
} else if (cbKvState === 'warning') {
|
|
1612
|
+
circuitBreaker = 'active'; // Warning is still operational
|
|
1613
|
+
} else {
|
|
1614
|
+
circuitBreaker = 'active';
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Determine operational status
|
|
1619
|
+
let status: 'RUN' | 'WARN' | 'STOP' = 'RUN';
|
|
1620
|
+
if (circuitBreaker === 'tripped') {
|
|
1621
|
+
status = 'STOP';
|
|
1622
|
+
} else if (percentage > 100) {
|
|
1623
|
+
status = 'STOP';
|
|
1624
|
+
} else if (percentage > 80 || cbKvState === 'warning') {
|
|
1625
|
+
status = 'WARN';
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
projects[projectId] = {
|
|
1629
|
+
status,
|
|
1630
|
+
spend,
|
|
1631
|
+
cap,
|
|
1632
|
+
percentage: Math.round(percentage * 10) / 10,
|
|
1633
|
+
circuitBreaker,
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
return jsonResponse({
|
|
1638
|
+
success: true,
|
|
1639
|
+
period,
|
|
1640
|
+
projects,
|
|
1641
|
+
timestamp: new Date().toISOString(),
|
|
1642
|
+
responseTimeMs: Date.now() - startTime,
|
|
1643
|
+
});
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1646
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:status');
|
|
1647
|
+
log.error('Error fetching status', error instanceof Error ? error : undefined, {
|
|
1648
|
+
errorMessage,
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
return jsonResponse(
|
|
1652
|
+
{
|
|
1653
|
+
success: false,
|
|
1654
|
+
error: 'Failed to fetch status',
|
|
1655
|
+
message: errorMessage,
|
|
1656
|
+
},
|
|
1657
|
+
500
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Handle GET /usage/projects
|
|
1664
|
+
*
|
|
1665
|
+
* Returns the list of projects from the D1 registry with resource counts,
|
|
1666
|
+
* service allowances, and projected monthly cost based on MTD burn rate.
|
|
1667
|
+
* Used by the dashboard to populate project selectors and show overview.
|
|
1668
|
+
*/
|
|
1669
|
+
export async function handleProjects(env: Env): Promise<Response> {
|
|
1670
|
+
const startTime = Date.now();
|
|
1671
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:projects');
|
|
1672
|
+
|
|
1673
|
+
// Cache key (hourly refresh)
|
|
1674
|
+
const hourTimestamp = Math.floor(Date.now() / (60 * 60 * 1000));
|
|
1675
|
+
const cacheKey = `projects:list:v2:${hourTimestamp}`;
|
|
1676
|
+
|
|
1677
|
+
// Check cache first
|
|
1678
|
+
try {
|
|
1679
|
+
const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as ProjectListResponse | null;
|
|
1680
|
+
if (cached) {
|
|
1681
|
+
log.info('Projects cache hit', { tag: 'CACHE_HIT', cacheKey });
|
|
1682
|
+
return jsonResponse({
|
|
1683
|
+
...cached,
|
|
1684
|
+
cached: true,
|
|
1685
|
+
responseTimeMs: Date.now() - startTime,
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
log.error('Projects cache read error', error instanceof Error ? error : undefined, {
|
|
1690
|
+
tag: 'CACHE_READ_ERROR',
|
|
1691
|
+
cacheKey,
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
try {
|
|
1696
|
+
// Get all projects from registry
|
|
1697
|
+
const projects = await getProjects(env.PLATFORM_DB);
|
|
1698
|
+
|
|
1699
|
+
// Get resource counts per project
|
|
1700
|
+
const resourceCounts = new Map<string, number>();
|
|
1701
|
+
const countResult = await env.PLATFORM_DB.prepare(
|
|
1702
|
+
`
|
|
1703
|
+
SELECT project_id, COUNT(*) as count
|
|
1704
|
+
FROM resource_project_mapping
|
|
1705
|
+
GROUP BY project_id
|
|
1706
|
+
`
|
|
1707
|
+
).all<{ project_id: string; count: number }>();
|
|
1708
|
+
|
|
1709
|
+
for (const row of countResult.results ?? []) {
|
|
1710
|
+
resourceCounts.set(row.project_id, row.count);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Merge counts with projects
|
|
1714
|
+
const projectsWithCounts = projects.map((p: Project) => ({
|
|
1715
|
+
...p,
|
|
1716
|
+
resourceCount: resourceCounts.get(p.projectId) ?? 0,
|
|
1717
|
+
}));
|
|
1718
|
+
|
|
1719
|
+
// Sort by resource count (most resources first)
|
|
1720
|
+
projectsWithCounts.sort(
|
|
1721
|
+
(a: Project & { resourceCount: number }, b: Project & { resourceCount: number }) =>
|
|
1722
|
+
b.resourceCount - a.resourceCount
|
|
1723
|
+
);
|
|
1724
|
+
|
|
1725
|
+
const totalResources = projectsWithCounts.reduce(
|
|
1726
|
+
(sum: number, p: Project & { resourceCount: number }) => sum + p.resourceCount,
|
|
1727
|
+
0
|
|
1728
|
+
);
|
|
1729
|
+
|
|
1730
|
+
// Calculate projected cost from MTD daily_usage_rollups
|
|
1731
|
+
const now = new Date();
|
|
1732
|
+
const currentMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
1733
|
+
const daysPassed = now.getUTCDate();
|
|
1734
|
+
const daysInMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 0).getDate();
|
|
1735
|
+
|
|
1736
|
+
// Get MTD cost from daily rollups (sum across individual projects, not 'all' row)
|
|
1737
|
+
// This ensures we get complete data even if the 'all' rollup row is missing for some dates
|
|
1738
|
+
const mtdResult = await env.PLATFORM_DB.prepare(
|
|
1739
|
+
`
|
|
1740
|
+
SELECT SUM(total_cost_usd) as total_cost
|
|
1741
|
+
FROM daily_usage_rollups
|
|
1742
|
+
WHERE snapshot_date LIKE ? || '%'
|
|
1743
|
+
AND project NOT IN ('all', '_unattributed')
|
|
1744
|
+
`
|
|
1745
|
+
)
|
|
1746
|
+
.bind(currentMonth)
|
|
1747
|
+
.first<{ total_cost: number | null }>();
|
|
1748
|
+
|
|
1749
|
+
const currentCost = mtdResult?.total_cost ?? 0;
|
|
1750
|
+
const projectedMonthlyCost = daysPassed > 0 ? (currentCost / daysPassed) * daysInMonth : 0;
|
|
1751
|
+
|
|
1752
|
+
// Build allowances object from CF_SIMPLE_ALLOWANCES
|
|
1753
|
+
const allowances = {
|
|
1754
|
+
workers: { limit: CF_ALLOWANCES.workers.limit, unit: CF_ALLOWANCES.workers.unit },
|
|
1755
|
+
d1_writes: { limit: CF_ALLOWANCES.d1.limit, unit: CF_ALLOWANCES.d1.unit },
|
|
1756
|
+
kv_writes: { limit: CF_ALLOWANCES.kv.limit, unit: CF_ALLOWANCES.kv.unit },
|
|
1757
|
+
r2_storage: { limit: CF_ALLOWANCES.r2.limit, unit: CF_ALLOWANCES.r2.unit },
|
|
1758
|
+
durableObjects: {
|
|
1759
|
+
limit: CF_ALLOWANCES.durableObjects.limit,
|
|
1760
|
+
unit: CF_ALLOWANCES.durableObjects.unit,
|
|
1761
|
+
},
|
|
1762
|
+
vectorize: { limit: CF_ALLOWANCES.vectorize.limit, unit: CF_ALLOWANCES.vectorize.unit },
|
|
1763
|
+
// GitHub Enterprise allowance (50K minutes/month)
|
|
1764
|
+
github_actions_minutes: { limit: 50000, unit: 'minutes' },
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
const projectedCost: ProjectedCost = {
|
|
1768
|
+
currentCost,
|
|
1769
|
+
daysPassed,
|
|
1770
|
+
daysInMonth,
|
|
1771
|
+
projectedMonthlyCost,
|
|
1772
|
+
};
|
|
1773
|
+
|
|
1774
|
+
const response: ProjectListResponse = {
|
|
1775
|
+
success: true,
|
|
1776
|
+
projects: projectsWithCounts,
|
|
1777
|
+
totalResources,
|
|
1778
|
+
timestamp: new Date().toISOString(),
|
|
1779
|
+
cached: false,
|
|
1780
|
+
allowances,
|
|
1781
|
+
projectedCost,
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
// Cache for 1 hour
|
|
1785
|
+
try {
|
|
1786
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 3600 });
|
|
1787
|
+
log.info('Projects cached', { tag: 'CACHE_WRITE', cacheKey });
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
log.error('Projects cache write error', error instanceof Error ? error : undefined, {
|
|
1790
|
+
tag: 'CACHE_WRITE_ERROR',
|
|
1791
|
+
cacheKey,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
log.info('Projects fetched', {
|
|
1796
|
+
tag: 'PROJECTS_FETCHED',
|
|
1797
|
+
durationMs: Date.now() - startTime,
|
|
1798
|
+
projectCount: projects.length,
|
|
1799
|
+
resourceCount: totalResources,
|
|
1800
|
+
projectedMonthlyCost: projectedMonthlyCost.toFixed(2),
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
return jsonResponse({
|
|
1804
|
+
...response,
|
|
1805
|
+
responseTimeMs: Date.now() - startTime,
|
|
1806
|
+
});
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1809
|
+
log.error('Error fetching projects', error instanceof Error ? error : undefined, {
|
|
1810
|
+
tag: 'PROJECTS_ERROR',
|
|
1811
|
+
errorMessage,
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
return jsonResponse(
|
|
1815
|
+
{
|
|
1816
|
+
success: false,
|
|
1817
|
+
error: 'Failed to fetch projects',
|
|
1818
|
+
message: errorMessage,
|
|
1819
|
+
},
|
|
1820
|
+
500
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Handle GET /usage/anomalies
|
|
1827
|
+
*
|
|
1828
|
+
* Returns detected usage anomalies from the D1 warehouse.
|
|
1829
|
+
* Supports filtering by days lookback and resolved status.
|
|
1830
|
+
*
|
|
1831
|
+
* Query params:
|
|
1832
|
+
* - days: Number of days to look back (default: 7, max: 30)
|
|
1833
|
+
* - resolved: 'all' | 'true' | 'false' (default: 'all')
|
|
1834
|
+
* - limit: Max results (default: 50, max: 100)
|
|
1835
|
+
*/
|
|
1836
|
+
export async function handleAnomalies(url: URL, env: Env): Promise<Response> {
|
|
1837
|
+
const startTime = Date.now();
|
|
1838
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:anomalies');
|
|
1839
|
+
|
|
1840
|
+
// Parse query params
|
|
1841
|
+
const daysParam = url.searchParams.get('days');
|
|
1842
|
+
const resolvedParam = url.searchParams.get('resolved') ?? 'all';
|
|
1843
|
+
const limitParam = url.searchParams.get('limit');
|
|
1844
|
+
|
|
1845
|
+
const days = Math.min(Math.max(parseInt(daysParam ?? '7', 10) || 7, 1), 30);
|
|
1846
|
+
const limit = Math.min(Math.max(parseInt(limitParam ?? '50', 10) || 50, 1), 100);
|
|
1847
|
+
|
|
1848
|
+
// Calculate lookback timestamp (days ago)
|
|
1849
|
+
const lookbackMs = days * 24 * 60 * 60 * 1000;
|
|
1850
|
+
const sinceTimestamp = Math.floor((Date.now() - lookbackMs) / 1000);
|
|
1851
|
+
|
|
1852
|
+
// Cache key (15-min TTL to balance freshness with cost)
|
|
1853
|
+
const cacheTimestamp = Math.floor(Date.now() / (15 * 60 * 1000));
|
|
1854
|
+
const cacheKey = `anomalies:${days}:${resolvedParam}:${limit}:${cacheTimestamp}`;
|
|
1855
|
+
|
|
1856
|
+
// Check cache first
|
|
1857
|
+
try {
|
|
1858
|
+
const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as AnomaliesResponse | null;
|
|
1859
|
+
if (cached) {
|
|
1860
|
+
log.info('Anomalies cache hit', { tag: 'CACHE_HIT', cacheKey });
|
|
1861
|
+
return jsonResponse({
|
|
1862
|
+
...cached,
|
|
1863
|
+
cached: true,
|
|
1864
|
+
responseTimeMs: Date.now() - startTime,
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
} catch (error) {
|
|
1868
|
+
log.error('Anomalies cache read error', error instanceof Error ? error : undefined, {
|
|
1869
|
+
tag: 'CACHE_READ_ERROR',
|
|
1870
|
+
cacheKey,
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
try {
|
|
1875
|
+
// Build query based on resolved filter
|
|
1876
|
+
let whereClause = 'WHERE detected_at >= ?';
|
|
1877
|
+
const params: (string | number)[] = [sinceTimestamp];
|
|
1878
|
+
|
|
1879
|
+
if (resolvedParam === 'true') {
|
|
1880
|
+
whereClause += ' AND resolved = 1';
|
|
1881
|
+
} else if (resolvedParam === 'false') {
|
|
1882
|
+
whereClause += ' AND resolved = 0';
|
|
1883
|
+
}
|
|
1884
|
+
// 'all' doesn't add a filter
|
|
1885
|
+
|
|
1886
|
+
const query = `
|
|
1887
|
+
SELECT id, detected_at, metric_name, project,
|
|
1888
|
+
current_value, rolling_avg, rolling_stddev, deviation_factor,
|
|
1889
|
+
alert_sent, alert_channel, resolved, resolved_at, resolved_by
|
|
1890
|
+
FROM usage_anomalies
|
|
1891
|
+
${whereClause}
|
|
1892
|
+
ORDER BY detected_at DESC
|
|
1893
|
+
LIMIT ?
|
|
1894
|
+
`;
|
|
1895
|
+
params.push(limit);
|
|
1896
|
+
|
|
1897
|
+
const result = await env.PLATFORM_DB.prepare(query)
|
|
1898
|
+
.bind(...params)
|
|
1899
|
+
.all<AnomalyRecord>();
|
|
1900
|
+
|
|
1901
|
+
// Count total anomalies (for pagination info)
|
|
1902
|
+
const countQuery = `SELECT COUNT(*) as count FROM usage_anomalies ${whereClause}`;
|
|
1903
|
+
const countResult = await env.PLATFORM_DB.prepare(countQuery)
|
|
1904
|
+
.bind(...params.slice(0, -1)) // Exclude LIMIT param
|
|
1905
|
+
.first<{ count: number }>();
|
|
1906
|
+
|
|
1907
|
+
const anomalies = (result.results ?? []).map((row) => ({
|
|
1908
|
+
id: row.id,
|
|
1909
|
+
detectedAt: new Date(row.detected_at * 1000).toISOString(),
|
|
1910
|
+
metric: row.metric_name,
|
|
1911
|
+
project: row.project,
|
|
1912
|
+
currentValue: row.current_value,
|
|
1913
|
+
rollingAvg: row.rolling_avg,
|
|
1914
|
+
deviationFactor: Math.round(row.deviation_factor * 10) / 10,
|
|
1915
|
+
alertSent: row.alert_sent === 1,
|
|
1916
|
+
alertChannel: row.alert_channel,
|
|
1917
|
+
resolved: row.resolved === 1,
|
|
1918
|
+
resolvedAt: row.resolved_at ? new Date(row.resolved_at * 1000).toISOString() : null,
|
|
1919
|
+
resolvedBy: row.resolved_by,
|
|
1920
|
+
}));
|
|
1921
|
+
|
|
1922
|
+
const response: AnomaliesResponse = {
|
|
1923
|
+
success: true,
|
|
1924
|
+
anomalies,
|
|
1925
|
+
total: countResult?.count ?? anomalies.length,
|
|
1926
|
+
timestamp: new Date().toISOString(),
|
|
1927
|
+
cached: false,
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
// Cache for 15 minutes
|
|
1931
|
+
try {
|
|
1932
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), {
|
|
1933
|
+
expirationTtl: 15 * 60,
|
|
1934
|
+
});
|
|
1935
|
+
} catch (error) {
|
|
1936
|
+
log.error('Anomalies cache write error', error instanceof Error ? error : undefined, {
|
|
1937
|
+
tag: 'CACHE_WRITE_ERROR',
|
|
1938
|
+
cacheKey,
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
log.info('Anomalies fetched', {
|
|
1943
|
+
tag: 'ANOMALIES_FETCHED',
|
|
1944
|
+
durationMs: Date.now() - startTime,
|
|
1945
|
+
anomalyCount: anomalies.length,
|
|
1946
|
+
totalCount: countResult?.count ?? 0,
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
return jsonResponse({
|
|
1950
|
+
...response,
|
|
1951
|
+
responseTimeMs: Date.now() - startTime,
|
|
1952
|
+
});
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1955
|
+
log.error('Error fetching anomalies', error instanceof Error ? error : undefined, {
|
|
1956
|
+
tag: 'ANOMALIES_ERROR',
|
|
1957
|
+
errorMessage,
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
// Return empty array on error (table may not exist yet)
|
|
1961
|
+
return jsonResponse({
|
|
1962
|
+
success: true,
|
|
1963
|
+
anomalies: [],
|
|
1964
|
+
total: 0,
|
|
1965
|
+
timestamp: new Date().toISOString(),
|
|
1966
|
+
cached: false,
|
|
1967
|
+
error: errorMessage,
|
|
1968
|
+
responseTimeMs: Date.now() - startTime,
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Handle GET /usage/utilization (task-26)
|
|
1975
|
+
*
|
|
1976
|
+
* Returns burn rate data and per-project utilization for the dashboard.
|
|
1977
|
+
* Includes MTD spend, projected monthly total, and project-level metrics.
|
|
1978
|
+
*/
|
|
1979
|
+
export async function handleUtilization(url: URL, env: Env): Promise<Response> {
|
|
1980
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:utilization');
|
|
1981
|
+
const startTime = Date.now();
|
|
1982
|
+
|
|
1983
|
+
// Cache key (hourly)
|
|
1984
|
+
const hourTimestamp = Math.floor(Date.now() / (60 * 60 * 1000));
|
|
1985
|
+
const cacheKey = `utilization:${hourTimestamp}`;
|
|
1986
|
+
|
|
1987
|
+
// Check cache bypass
|
|
1988
|
+
const noCache = url.searchParams.get('nocache') === 'true';
|
|
1989
|
+
|
|
1990
|
+
if (!noCache) {
|
|
1991
|
+
try {
|
|
1992
|
+
const cached = (await env.PLATFORM_CACHE.get(cacheKey, 'json')) as BurnRateResponse | null;
|
|
1993
|
+
if (cached) {
|
|
1994
|
+
log.info(`Utilization cache hit for ${cacheKey}`, { tag: 'USAGE' });
|
|
1995
|
+
return jsonResponse({
|
|
1996
|
+
...cached,
|
|
1997
|
+
cached: true,
|
|
1998
|
+
responseTimeMs: Date.now() - startTime,
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
} catch (error) {
|
|
2002
|
+
log.error(
|
|
2003
|
+
'Utilization cache read error',
|
|
2004
|
+
error instanceof Error ? error : new Error(String(error))
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
try {
|
|
2010
|
+
const now = new Date();
|
|
2011
|
+
const currentYear = now.getUTCFullYear();
|
|
2012
|
+
const currentMonth = now.getUTCMonth();
|
|
2013
|
+
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
|
|
2014
|
+
const dayOfMonth = now.getUTCDate();
|
|
2015
|
+
const daysRemaining = daysInMonth - dayOfMonth;
|
|
2016
|
+
|
|
2017
|
+
// Get MTD dates
|
|
2018
|
+
const mtdStartDate = new Date(Date.UTC(currentYear, currentMonth, 1))
|
|
2019
|
+
.toISOString()
|
|
2020
|
+
.slice(0, 10);
|
|
2021
|
+
const mtdEndDate = now.toISOString().slice(0, 10);
|
|
2022
|
+
|
|
2023
|
+
// Billing period (first to last of month)
|
|
2024
|
+
const billingStart = new Date(Date.UTC(currentYear, currentMonth, 1))
|
|
2025
|
+
.toISOString()
|
|
2026
|
+
.slice(0, 10);
|
|
2027
|
+
const billingEnd = new Date(Date.UTC(currentYear, currentMonth + 1, 0))
|
|
2028
|
+
.toISOString()
|
|
2029
|
+
.slice(0, 10);
|
|
2030
|
+
|
|
2031
|
+
// Query D1 for MTD costs (using 30d period or custom range)
|
|
2032
|
+
const d1Data = await queryD1DailyCosts(env, { start: mtdStartDate, end: mtdEndDate }, 'all');
|
|
2033
|
+
|
|
2034
|
+
// Get projects from D1 registry
|
|
2035
|
+
const allProjects = await getProjects(env.PLATFORM_DB);
|
|
2036
|
+
// Filter to only projects with usage config (from D1 or fallback)
|
|
2037
|
+
const projectsWithConfig = allProjects
|
|
2038
|
+
.map((p: Project) => ({ project: p, config: getProjectConfig(p) }))
|
|
2039
|
+
.filter(
|
|
2040
|
+
(p: {
|
|
2041
|
+
project: Project;
|
|
2042
|
+
config: ReturnType<typeof getProjectConfig>;
|
|
2043
|
+
}): p is { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> } =>
|
|
2044
|
+
p.config !== null
|
|
2045
|
+
);
|
|
2046
|
+
|
|
2047
|
+
// Query per-project MTD costs
|
|
2048
|
+
const perProjectCosts: Record<string, { mtdCost: number; sparkline: number[] }> = {};
|
|
2049
|
+
const projectIds = projectsWithConfig.map(
|
|
2050
|
+
(p: { project: Project; config: NonNullable<ReturnType<typeof getProjectConfig>> }) =>
|
|
2051
|
+
p.project.projectId
|
|
2052
|
+
);
|
|
2053
|
+
|
|
2054
|
+
// Batch query per-project costs in parallel
|
|
2055
|
+
const projectCostPromises = projectIds.map(async (projectId: string) => {
|
|
2056
|
+
const projectData = await queryD1DailyCosts(
|
|
2057
|
+
env,
|
|
2058
|
+
{ start: mtdStartDate, end: mtdEndDate },
|
|
2059
|
+
projectId
|
|
2060
|
+
);
|
|
2061
|
+
const projectMtdCost = projectData?.totals?.total ?? 0;
|
|
2062
|
+
const projectSparkline = projectData?.days?.map((d) => d.total) ?? [];
|
|
2063
|
+
return { projectId, mtdCost: projectMtdCost, sparkline: projectSparkline };
|
|
2064
|
+
});
|
|
2065
|
+
const projectCostResults = await Promise.all(projectCostPromises);
|
|
2066
|
+
for (const result of projectCostResults) {
|
|
2067
|
+
perProjectCosts[result.projectId] = { mtdCost: result.mtdCost, sparkline: result.sparkline };
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// Query last month's per-project MTD costs for individual delta calculations
|
|
2071
|
+
const lastMonth = new Date(Date.UTC(currentYear, currentMonth - 1, 1));
|
|
2072
|
+
const lastMonthStart = lastMonth.toISOString().slice(0, 10);
|
|
2073
|
+
const lastMonthEnd = new Date(
|
|
2074
|
+
Date.UTC(lastMonth.getFullYear(), lastMonth.getMonth(), dayOfMonth)
|
|
2075
|
+
)
|
|
2076
|
+
.toISOString()
|
|
2077
|
+
.slice(0, 10);
|
|
2078
|
+
|
|
2079
|
+
const perProjectLastMonthCosts: Record<string, number> = {};
|
|
2080
|
+
const lastMonthProjectPromises = projectIds.map(async (projectId: string) => {
|
|
2081
|
+
try {
|
|
2082
|
+
const lastMonthProjectData = await queryD1DailyCosts(
|
|
2083
|
+
env,
|
|
2084
|
+
{ start: lastMonthStart, end: lastMonthEnd },
|
|
2085
|
+
projectId
|
|
2086
|
+
);
|
|
2087
|
+
return { projectId, lastMonthCost: lastMonthProjectData?.totals?.total ?? 0 };
|
|
2088
|
+
} catch {
|
|
2089
|
+
return { projectId, lastMonthCost: 0 };
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
const lastMonthProjectResults = await Promise.all(lastMonthProjectPromises);
|
|
2093
|
+
for (const result of lastMonthProjectResults) {
|
|
2094
|
+
perProjectLastMonthCosts[result.projectId] = result.lastMonthCost;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// Calculate total MTD cost (use 'all' query which aggregates correctly)
|
|
2098
|
+
const mtdCost = d1Data?.totals?.total ?? 0;
|
|
2099
|
+
const dailyBurnRate = dayOfMonth > 0 ? mtdCost / dayOfMonth : 0;
|
|
2100
|
+
const projectedMonthlyCost = dailyBurnRate * daysInMonth;
|
|
2101
|
+
|
|
2102
|
+
// Confidence based on days of data
|
|
2103
|
+
let confidence: 'low' | 'medium' | 'high' = 'low';
|
|
2104
|
+
if (dayOfMonth >= 15) confidence = 'high';
|
|
2105
|
+
else if (dayOfMonth >= 7) confidence = 'medium';
|
|
2106
|
+
|
|
2107
|
+
// Get last month's MTD cost for account-level comparison (uses dates defined above)
|
|
2108
|
+
let vsLastMonthPct: number | null = null;
|
|
2109
|
+
try {
|
|
2110
|
+
const lastMonthData = await queryD1DailyCosts(env, {
|
|
2111
|
+
start: lastMonthStart,
|
|
2112
|
+
end: lastMonthEnd,
|
|
2113
|
+
});
|
|
2114
|
+
if (lastMonthData?.totals?.total && lastMonthData.totals.total > 0) {
|
|
2115
|
+
vsLastMonthPct =
|
|
2116
|
+
((mtdCost - lastMonthData.totals.total) / lastMonthData.totals.total) * 100;
|
|
2117
|
+
}
|
|
2118
|
+
} catch (error) {
|
|
2119
|
+
log.warn('Could not fetch last month data for comparison', undefined, {
|
|
2120
|
+
tag: 'USAGE',
|
|
2121
|
+
error: String(error),
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// Get budget thresholds from D1 (with fallback defaults)
|
|
2126
|
+
const { softBudgetLimit, warningThreshold } = await getBudgetThresholds(env);
|
|
2127
|
+
|
|
2128
|
+
// Determine overall status
|
|
2129
|
+
let status: 'green' | 'yellow' | 'red' = 'green';
|
|
2130
|
+
let statusLabel = 'On Track';
|
|
2131
|
+
let statusDetail = 'Under budget';
|
|
2132
|
+
|
|
2133
|
+
if (projectedMonthlyCost > softBudgetLimit) {
|
|
2134
|
+
const overageAmount = projectedMonthlyCost - softBudgetLimit;
|
|
2135
|
+
status = 'red';
|
|
2136
|
+
statusLabel = 'Over Budget';
|
|
2137
|
+
statusDetail = `Projected $${overageAmount.toFixed(2)} over $${softBudgetLimit} limit`;
|
|
2138
|
+
} else if (projectedMonthlyCost > warningThreshold) {
|
|
2139
|
+
status = 'yellow';
|
|
2140
|
+
statusLabel = 'Elevated';
|
|
2141
|
+
statusDetail = `$${(softBudgetLimit - projectedMonthlyCost).toFixed(2)} headroom to $${softBudgetLimit} limit`;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// Get project-level data
|
|
2145
|
+
const projectData: ProjectUtilizationData[] = [];
|
|
2146
|
+
|
|
2147
|
+
// Query for 7-day sparkline data per project (already fetched above in perProjectCosts)
|
|
2148
|
+
const sparklineStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
|
2149
|
+
.toISOString()
|
|
2150
|
+
.slice(0, 10);
|
|
2151
|
+
|
|
2152
|
+
// Fetch 7-day sparkline data per project in parallel
|
|
2153
|
+
const sparklinePromises = projectIds.map(async (projectId: string) => {
|
|
2154
|
+
const sparkData = await queryD1DailyCosts(
|
|
2155
|
+
env,
|
|
2156
|
+
{ start: sparklineStart, end: mtdEndDate },
|
|
2157
|
+
projectId
|
|
2158
|
+
);
|
|
2159
|
+
return { projectId, sparkline: sparkData?.days?.map((d) => d.total) ?? [] };
|
|
2160
|
+
});
|
|
2161
|
+
const sparklineResults = await Promise.all(sparklinePromises);
|
|
2162
|
+
const projectSparklines: Record<string, number[]> = {};
|
|
2163
|
+
for (const result of sparklineResults) {
|
|
2164
|
+
projectSparklines[result.projectId] = result.sparkline;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// Get circuit breaker status from KV for all registered projects
|
|
2168
|
+
const cbStatuses: Record<string, string> = {};
|
|
2169
|
+
{
|
|
2170
|
+
const projectRows2 = await env.PLATFORM_DB.prepare(
|
|
2171
|
+
`SELECT project_id FROM project_registry WHERE project_id != 'all'`
|
|
2172
|
+
).all<{ project_id: string }>();
|
|
2173
|
+
const projectIds2 = projectRows2.results?.map((r) => r.project_id) ?? ['platform'];
|
|
2174
|
+
const cbResults2 = await Promise.all(
|
|
2175
|
+
projectIds2.map(async (pid) => {
|
|
2176
|
+
const cbKey = `PROJECT:${pid.toUpperCase().replace(/-/g, '-')}:STATUS`;
|
|
2177
|
+
const status = await env.PLATFORM_CACHE.get(cbKey);
|
|
2178
|
+
return { pid, status };
|
|
2179
|
+
})
|
|
2180
|
+
);
|
|
2181
|
+
for (const { pid, status } of cbResults2) {
|
|
2182
|
+
cbStatuses[pid] = status ?? 'active';
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Query feature_registry to find which projects have CB-enabled features
|
|
2187
|
+
// This determines whether to show CB indicator as active vs disabled
|
|
2188
|
+
const projectsWithCBEnabled = new Set<string>();
|
|
2189
|
+
try {
|
|
2190
|
+
const cbEnabledResult = await env.PLATFORM_DB.prepare(
|
|
2191
|
+
`
|
|
2192
|
+
SELECT DISTINCT project_id
|
|
2193
|
+
FROM feature_registry
|
|
2194
|
+
WHERE circuit_breaker_enabled = 1
|
|
2195
|
+
`
|
|
2196
|
+
).all();
|
|
2197
|
+
for (const row of cbEnabledResult.results ?? []) {
|
|
2198
|
+
projectsWithCBEnabled.add(row.project_id as string);
|
|
2199
|
+
}
|
|
2200
|
+
log.info(`Projects with CB enabled: ${[...projectsWithCBEnabled].join(', ')}`, {
|
|
2201
|
+
tag: 'USAGE',
|
|
2202
|
+
});
|
|
2203
|
+
} catch (err) {
|
|
2204
|
+
// feature_registry may not exist - fall back to showing all as enabled
|
|
2205
|
+
log.warn('Could not query feature_registry for CB status', undefined, {
|
|
2206
|
+
tag: 'USAGE',
|
|
2207
|
+
error: String(err),
|
|
2208
|
+
});
|
|
2209
|
+
// Fallback: add all known projects from cbStatuses
|
|
2210
|
+
for (const pid of Object.keys(cbStatuses)) {
|
|
2211
|
+
projectsWithCBEnabled.add(pid);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// Build project-level utilization using ACTUAL per-project data
|
|
2216
|
+
for (const { project, config } of projectsWithConfig) {
|
|
2217
|
+
const projectId = project.projectId;
|
|
2218
|
+
const primaryResource = config.primaryResource;
|
|
2219
|
+
const limit = config.customLimit ?? CF_ALLOWANCES[primaryResource].limit;
|
|
2220
|
+
const unit = CF_ALLOWANCES[primaryResource].unit;
|
|
2221
|
+
|
|
2222
|
+
// Get project-specific costs from per-project query (not divided total)
|
|
2223
|
+
const projectCostData = perProjectCosts[projectId];
|
|
2224
|
+
const projectMtdCost = projectCostData?.mtdCost ?? 0;
|
|
2225
|
+
|
|
2226
|
+
// Get actual current usage for primary resource from per-project D1 query
|
|
2227
|
+
// We query per-project daily data to get proper attribution
|
|
2228
|
+
let currentUsage = 0;
|
|
2229
|
+
const projectD1Data = await queryD1DailyCosts(
|
|
2230
|
+
env,
|
|
2231
|
+
{ start: mtdStartDate, end: mtdEndDate },
|
|
2232
|
+
projectId
|
|
2233
|
+
);
|
|
2234
|
+
if (projectD1Data?.totals) {
|
|
2235
|
+
switch (primaryResource) {
|
|
2236
|
+
case 'workers': {
|
|
2237
|
+
// Reverse cost calculation: cost / $0.30 per million * 1M = requests
|
|
2238
|
+
// Plus base cost consideration (~$0.17/day for $5/month)
|
|
2239
|
+
const workersUsageCost = Math.max(
|
|
2240
|
+
0,
|
|
2241
|
+
projectD1Data.totals.workers - (5 / 30) * dayOfMonth
|
|
2242
|
+
);
|
|
2243
|
+
currentUsage = (workersUsageCost / 0.3) * 1_000_000;
|
|
2244
|
+
break;
|
|
2245
|
+
}
|
|
2246
|
+
case 'd1':
|
|
2247
|
+
// D1 writes: cost * 1M rows (since $1 per million rows written)
|
|
2248
|
+
currentUsage = projectD1Data.totals.d1 * 1_000_000;
|
|
2249
|
+
break;
|
|
2250
|
+
case 'vectorize':
|
|
2251
|
+
// Vectorize: cost / $0.01 per million * 1M = dimensions
|
|
2252
|
+
currentUsage = (projectD1Data.totals.vectorize / 0.01) * 1_000_000;
|
|
2253
|
+
break;
|
|
2254
|
+
case 'kv':
|
|
2255
|
+
// KV writes: cost / $5 per million * 1M = writes
|
|
2256
|
+
currentUsage = (projectD1Data.totals.kv / 5) * 1_000_000;
|
|
2257
|
+
break;
|
|
2258
|
+
case 'r2':
|
|
2259
|
+
// R2: cost / $4.50 per million * 1M = Class A ops
|
|
2260
|
+
currentUsage = (projectD1Data.totals.r2 / 4.5) * 1_000_000;
|
|
2261
|
+
break;
|
|
2262
|
+
case 'durableObjects':
|
|
2263
|
+
// DO: cost / $1 per million * 1M = requests (after 3M included in Workers Paid)
|
|
2264
|
+
currentUsage = (projectD1Data.totals.durableObjects / 1) * 1_000_000;
|
|
2265
|
+
break;
|
|
2266
|
+
case 'queues':
|
|
2267
|
+
// Queues: cost / $0.40 per million * 1M = messages (after 1M free)
|
|
2268
|
+
currentUsage = (projectD1Data.totals.queues / 0.4) * 1_000_000;
|
|
2269
|
+
break;
|
|
2270
|
+
default:
|
|
2271
|
+
currentUsage = 0;
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const utilizationPct = limit > 0 ? (currentUsage / limit) * 100 : 0;
|
|
2276
|
+
const projectStatus = getUtilizationStatus(utilizationPct);
|
|
2277
|
+
|
|
2278
|
+
// Use per-project sparkline data
|
|
2279
|
+
const sparkline = projectSparklines[projectId] ?? [];
|
|
2280
|
+
|
|
2281
|
+
// Check if this project has CB enabled in feature_registry
|
|
2282
|
+
const hasCBEnabled = projectsWithCBEnabled.has(projectId);
|
|
2283
|
+
|
|
2284
|
+
// Only show actual CB status if the project has CB enabled features
|
|
2285
|
+
let cbStatusMapped: 'active' | 'tripped' | 'degraded' | 'disabled';
|
|
2286
|
+
let cbLabel: string;
|
|
2287
|
+
|
|
2288
|
+
if (hasCBEnabled) {
|
|
2289
|
+
const cbStatus = cbStatuses[projectId];
|
|
2290
|
+
cbStatusMapped =
|
|
2291
|
+
cbStatus === 'paused' ? 'tripped' : cbStatus === 'degraded' ? 'degraded' : 'active';
|
|
2292
|
+
cbLabel = cbStatusMapped === 'active' ? 'CB Active' : 'CB Tripped';
|
|
2293
|
+
} else {
|
|
2294
|
+
cbStatusMapped = 'disabled';
|
|
2295
|
+
cbLabel = 'No CB';
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// Calculate per-project cost delta vs last month (same period)
|
|
2299
|
+
// Use $0.10 threshold to avoid astronomical percentages from near-zero baselines
|
|
2300
|
+
const projectLastMonthCost = perProjectLastMonthCosts[projectId] ?? 0;
|
|
2301
|
+
const MIN_BASELINE_COST = 0.1;
|
|
2302
|
+
let projectCostDeltaPct: number | null = null;
|
|
2303
|
+
if (projectLastMonthCost >= MIN_BASELINE_COST) {
|
|
2304
|
+
projectCostDeltaPct =
|
|
2305
|
+
((projectMtdCost - projectLastMonthCost) / projectLastMonthCost) * 100;
|
|
2306
|
+
} else if (projectMtdCost >= MIN_BASELINE_COST) {
|
|
2307
|
+
// New project with meaningful current cost but no baseline - show as NEW
|
|
2308
|
+
projectCostDeltaPct = null;
|
|
2309
|
+
}
|
|
2310
|
+
// If both are below threshold, leave as null (will show "--" in UI)
|
|
2311
|
+
|
|
2312
|
+
projectData.push({
|
|
2313
|
+
projectId,
|
|
2314
|
+
projectName: project.displayName,
|
|
2315
|
+
primaryResource:
|
|
2316
|
+
primaryResource.charAt(0).toUpperCase() + primaryResource.slice(1).replace('AI', ' AI'),
|
|
2317
|
+
mtdCost: projectMtdCost,
|
|
2318
|
+
costDeltaPct: projectCostDeltaPct ?? 0,
|
|
2319
|
+
utilizationPct: Math.min(utilizationPct, 999),
|
|
2320
|
+
utilizationCurrent: Math.round(currentUsage),
|
|
2321
|
+
utilizationLimit: limit,
|
|
2322
|
+
utilizationUnit: unit,
|
|
2323
|
+
status: projectStatus,
|
|
2324
|
+
sparklineData: sparkline.length > 0 ? sparkline : [0, 0, 0, 0, 0, 0, 0],
|
|
2325
|
+
circuitBreakerStatus: cbStatusMapped,
|
|
2326
|
+
circuitBreakerLabel: cbLabel,
|
|
2327
|
+
hasCBEnabled,
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Query GitHub usage data (third-party provider)
|
|
2332
|
+
const githubData = await queryGitHubUsage(env);
|
|
2333
|
+
|
|
2334
|
+
// Build service-level utilization metrics for overview page
|
|
2335
|
+
const defaultTotals = {
|
|
2336
|
+
workers: 0,
|
|
2337
|
+
d1: 0,
|
|
2338
|
+
kv: 0,
|
|
2339
|
+
r2: 0,
|
|
2340
|
+
vectorize: 0,
|
|
2341
|
+
aiGateway: 0,
|
|
2342
|
+
durableObjects: 0,
|
|
2343
|
+
workersAI: 0,
|
|
2344
|
+
queues: 0,
|
|
2345
|
+
pages: 0,
|
|
2346
|
+
workflows: 0,
|
|
2347
|
+
total: 0,
|
|
2348
|
+
};
|
|
2349
|
+
const cloudflareServices = buildCloudflareServiceMetrics(
|
|
2350
|
+
d1Data?.totals ?? defaultTotals,
|
|
2351
|
+
dayOfMonth
|
|
2352
|
+
);
|
|
2353
|
+
const githubServices = buildGitHubServiceMetrics(githubData);
|
|
2354
|
+
|
|
2355
|
+
// Calculate provider health summaries
|
|
2356
|
+
const cloudflareHealth = calculateProviderHealth(cloudflareServices, 'cloudflare');
|
|
2357
|
+
const githubHealth = calculateProviderHealth(githubServices, 'github');
|
|
2358
|
+
|
|
2359
|
+
const response: BurnRateResponse = {
|
|
2360
|
+
success: true,
|
|
2361
|
+
burnRate: {
|
|
2362
|
+
mtdCost,
|
|
2363
|
+
mtdStartDate,
|
|
2364
|
+
mtdEndDate,
|
|
2365
|
+
projectedMonthlyCost,
|
|
2366
|
+
dailyBurnRate,
|
|
2367
|
+
daysIntoMonth: dayOfMonth,
|
|
2368
|
+
daysRemaining,
|
|
2369
|
+
confidence,
|
|
2370
|
+
vsLastMonthPct,
|
|
2371
|
+
billingPeriodStart: billingStart,
|
|
2372
|
+
billingPeriodEnd: billingEnd,
|
|
2373
|
+
status,
|
|
2374
|
+
statusLabel,
|
|
2375
|
+
statusDetail,
|
|
2376
|
+
},
|
|
2377
|
+
projects: projectData,
|
|
2378
|
+
github: githubData,
|
|
2379
|
+
health: {
|
|
2380
|
+
cloudflare: cloudflareHealth,
|
|
2381
|
+
github: githubHealth,
|
|
2382
|
+
},
|
|
2383
|
+
cloudflareServices,
|
|
2384
|
+
githubServices,
|
|
2385
|
+
timestamp: new Date().toISOString(),
|
|
2386
|
+
cached: false,
|
|
2387
|
+
};
|
|
2388
|
+
|
|
2389
|
+
// Cache for 1 hour
|
|
2390
|
+
try {
|
|
2391
|
+
await env.PLATFORM_CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 3600 });
|
|
2392
|
+
log.info(`Utilization cached for ${cacheKey}`, { tag: 'USAGE' });
|
|
2393
|
+
} catch (error) {
|
|
2394
|
+
log.error(
|
|
2395
|
+
'Utilization cache write error',
|
|
2396
|
+
error instanceof Error ? error : new Error(String(error))
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
return jsonResponse({
|
|
2401
|
+
...response,
|
|
2402
|
+
responseTimeMs: Date.now() - startTime,
|
|
2403
|
+
});
|
|
2404
|
+
} catch (error) {
|
|
2405
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2406
|
+
log.error(
|
|
2407
|
+
'Error fetching utilization data',
|
|
2408
|
+
error instanceof Error ? error : new Error(errorMessage)
|
|
2409
|
+
);
|
|
2410
|
+
|
|
2411
|
+
return jsonResponse(
|
|
2412
|
+
{
|
|
2413
|
+
success: false,
|
|
2414
|
+
error: 'Failed to fetch utilization data',
|
|
2415
|
+
message: errorMessage,
|
|
2416
|
+
},
|
|
2417
|
+
500
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
}
|