@littlebearapps/platform-admin-sdk 1.4.2 → 2.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.
Files changed (189) hide show
  1. package/dist/templates.d.ts +1 -1
  2. package/dist/templates.js +232 -2
  3. package/package.json +1 -1
  4. package/templates/full/config/audit-targets.yaml +72 -0
  5. package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
  7. package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
  8. package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
  9. package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
  10. package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
  11. package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
  12. package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
  13. package/templates/full/dashboard/src/components/patterns/index.ts +5 -0
  14. package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
  15. package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
  16. package/templates/full/dashboard/src/components/reports/index.ts +2 -0
  17. package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
  18. package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
  19. package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
  20. package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
  21. package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
  22. package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
  23. package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
  24. package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
  25. package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
  26. package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
  27. package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
  28. package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
  29. package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
  30. package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
  31. package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
  32. package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
  33. package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
  34. package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
  35. package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
  36. package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
  37. package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
  38. package/templates/full/dashboard/src/pages/notifications.astro +11 -0
  39. package/templates/full/migrations/008_auditor.sql +99 -0
  40. package/templates/full/migrations/010_pricing_versions.sql +110 -0
  41. package/templates/full/migrations/011_multi_account.sql +51 -0
  42. package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
  43. package/templates/full/scripts/ops/universal-backfill.ts +147 -0
  44. package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
  45. package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
  46. package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
  47. package/templates/full/workers/lib/auditor/index.ts +9 -0
  48. package/templates/full/workers/lib/auditor/types.ts +167 -0
  49. package/templates/full/workers/platform-auditor.ts +1071 -0
  50. package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
  51. package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
  52. package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
  53. package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
  54. package/templates/shared/.github/workflows/security.yml +33 -0
  55. package/templates/shared/config/observability.yaml.hbs +276 -0
  56. package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
  57. package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
  58. package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
  59. package/templates/shared/dashboard/astro.config.mjs +21 -0
  60. package/templates/shared/dashboard/package.json.hbs +29 -0
  61. package/templates/shared/dashboard/src/components/Header.astro +29 -0
  62. package/templates/shared/dashboard/src/components/Nav.astro.hbs +59 -0
  63. package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
  64. package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
  65. package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
  66. package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
  67. package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
  68. package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
  69. package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
  70. package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
  71. package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
  72. package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
  73. package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
  74. package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
  75. package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
  76. package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
  77. package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
  78. package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
  79. package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
  80. package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
  81. package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
  82. package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
  83. package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
  84. package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
  85. package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
  86. package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
  87. package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
  88. package/templates/shared/dashboard/src/components/ui/index.ts +9 -0
  89. package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
  90. package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
  91. package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
  92. package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
  93. package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
  94. package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
  95. package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
  96. package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
  97. package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
  98. package/templates/shared/dashboard/src/lib/types.ts +72 -0
  99. package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
  100. package/templates/shared/dashboard/src/middleware/index.ts +1 -0
  101. package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
  102. package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
  103. package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
  104. package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
  105. package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
  106. package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
  107. package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
  108. package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
  109. package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
  110. package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
  111. package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
  112. package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
  113. package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
  114. package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
  115. package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
  116. package/templates/shared/dashboard/src/pages/index.astro +3 -0
  117. package/templates/shared/dashboard/src/pages/resources.astro +11 -0
  118. package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
  119. package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
  120. package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
  121. package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
  122. package/templates/shared/dashboard/src/styles/global.css +29 -0
  123. package/templates/shared/dashboard/tailwind.config.mjs +9 -0
  124. package/templates/shared/dashboard/tsconfig.json +9 -0
  125. package/templates/shared/dashboard/wrangler.json.hbs +47 -0
  126. package/templates/shared/docs/architecture.md +89 -0
  127. package/templates/shared/docs/post-deploy-runbook.md +126 -0
  128. package/templates/shared/docs/troubleshooting.md +91 -0
  129. package/templates/shared/package.json.hbs +17 -1
  130. package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
  131. package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
  132. package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
  133. package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
  134. package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
  135. package/templates/shared/scripts/ops/validate-controls.js +141 -0
  136. package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
  137. package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
  138. package/templates/shared/scripts/validate-schemas.js +61 -0
  139. package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
  140. package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
  141. package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
  142. package/templates/shared/tests/helpers/mock-d1.ts +61 -0
  143. package/templates/shared/tests/helpers/mock-kv.ts +37 -0
  144. package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
  145. package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
  146. package/templates/shared/vitest.config.ts +18 -0
  147. package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
  148. package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
  149. package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
  150. package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
  151. package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
  152. package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
  153. package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
  154. package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
  155. package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
  156. package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
  157. package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
  158. package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
  159. package/templates/shared/workers/platform-usage.ts +98 -8
  160. package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
  161. package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
  162. package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
  163. package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
  164. package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
  165. package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
  166. package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
  167. package/templates/standard/dashboard/src/components/health/index.ts +4 -0
  168. package/templates/standard/dashboard/src/lib/errors.ts +28 -0
  169. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
  170. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
  171. package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
  172. package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
  173. package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
  174. package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
  175. package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
  176. package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
  177. package/templates/standard/dashboard/src/pages/errors.astro +13 -0
  178. package/templates/standard/dashboard/src/pages/health.astro +11 -0
  179. package/templates/standard/migrations/009_topology_mapper.sql +65 -0
  180. package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
  181. package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
  182. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
  183. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
  184. package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
  185. package/templates/standard/workers/lib/mapper/index.ts +7 -0
  186. package/templates/standard/workers/platform-mapper.ts +482 -0
  187. package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
  188. package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
  189. package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * OpenAI Usage Collector
3
+ *
4
+ * Collects API usage from the OpenAI Organization Usage API.
5
+ * Requires Admin API key (sk-admin-...) with read permissions.
6
+ *
7
+ * Fetches both completions and embeddings endpoints with group_by=model
8
+ * to get per-model breakdown.
9
+ *
10
+ * This is a FLOW metric (usage that accumulates) - safe to SUM.
11
+ *
12
+ * @see https://platform.openai.com/docs/api-reference/usage
13
+ */
14
+
15
+ import type { Env, OpenAIUsageData } from '../shared';
16
+ import { fetchWithRetry } from '../shared';
17
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
18
+ import type { ExternalCollector } from './index';
19
+
20
+ /** Response shape from OpenAI Usage API (completions + embeddings endpoints) */
21
+ interface UsageAPIResponse {
22
+ object?: string;
23
+ data?: Array<{
24
+ start_time?: number;
25
+ end_time?: number;
26
+ results?: Array<{
27
+ input_tokens?: number;
28
+ output_tokens?: number;
29
+ num_model_requests?: number;
30
+ project_id?: string;
31
+ user_id?: string;
32
+ api_key_id?: string;
33
+ model?: string;
34
+ }>;
35
+ }>;
36
+ }
37
+
38
+ /**
39
+ * Collect OpenAI API usage from the Organization Usage API.
40
+ * Requires Admin API key (sk-admin-...) with read permissions.
41
+ */
42
+ export async function collectOpenAIUsage(env: Env): Promise<OpenAIUsageData | null> {
43
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:openai');
44
+ if (!env.OPENAI_ADMIN_API_KEY) {
45
+ log.info('No OPENAI_ADMIN_API_KEY configured, skipping usage collection');
46
+ return null;
47
+ }
48
+
49
+ try {
50
+ // Get usage for yesterday (completed day)
51
+ const yesterday = new Date();
52
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1);
53
+ yesterday.setUTCHours(0, 0, 0, 0);
54
+ const startTime = Math.floor(yesterday.getTime() / 1000);
55
+
56
+ const headers = {
57
+ Authorization: `Bearer ${env.OPENAI_ADMIN_API_KEY}`,
58
+ 'Content-Type': 'application/json',
59
+ };
60
+
61
+ const result: OpenAIUsageData = {
62
+ inputTokens: 0,
63
+ outputTokens: 0,
64
+ totalTokens: 0,
65
+ totalCost: 0,
66
+ requestCount: 0,
67
+ modelBreakdown: {},
68
+ };
69
+
70
+ // Fetch completions and embeddings in parallel
71
+ const [completionsRes, embeddingsRes] = await Promise.all([
72
+ fetchWithRetry(
73
+ `https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&bucket_width=1d&group_by=model`,
74
+ { headers }
75
+ ),
76
+ fetchWithRetry(
77
+ `https://api.openai.com/v1/organization/usage/embeddings?start_time=${startTime}&bucket_width=1d&group_by=model`,
78
+ { headers }
79
+ ),
80
+ ]);
81
+
82
+ // Handle completions response
83
+ if (!completionsRes.ok) {
84
+ const errorText = await completionsRes.text();
85
+ if (completionsRes.status === 401 || completionsRes.status === 403) {
86
+ log.info('Usage API access denied - ensure using Admin API key with read permissions', {
87
+ status: completionsRes.status,
88
+ });
89
+ return null;
90
+ }
91
+ log.error(`Completions API returned ${completionsRes.status}: ${errorText}`);
92
+ return null;
93
+ }
94
+
95
+ const completionsData = (await completionsRes.json()) as UsageAPIResponse;
96
+ aggregateUsage(completionsData, result);
97
+
98
+ // Handle embeddings response (non-fatal — embeddings may not be used)
99
+ if (embeddingsRes.ok) {
100
+ const embeddingsData = (await embeddingsRes.json()) as UsageAPIResponse;
101
+ aggregateUsage(embeddingsData, result);
102
+ } else {
103
+ const errorText = await embeddingsRes.text();
104
+ log.info(`Embeddings API returned ${embeddingsRes.status} (non-fatal): ${errorText}`);
105
+ }
106
+
107
+ // Estimate cost from per-model breakdown
108
+ result.totalCost = 0;
109
+ for (const [, usage] of Object.entries(result.modelBreakdown)) {
110
+ result.totalCost += estimateModelCost(usage.inputTokens, usage.outputTokens);
111
+ }
112
+ // Fallback if no model breakdown but we have aggregate tokens
113
+ if (Object.keys(result.modelBreakdown).length === 0 && result.totalTokens > 0) {
114
+ result.totalCost = (result.inputTokens * 2.5 + result.outputTokens * 10) / 1_000_000;
115
+ }
116
+
117
+ log.info('Collected usage', {
118
+ inputTokens: result.inputTokens,
119
+ outputTokens: result.outputTokens,
120
+ requestCount: result.requestCount,
121
+ models: Object.keys(result.modelBreakdown).length,
122
+ estimatedCost: result.totalCost,
123
+ });
124
+ return result;
125
+ } catch (error) {
126
+ log.error('Failed to collect usage', error);
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Aggregate usage data from an API response into the result object */
132
+ function aggregateUsage(data: UsageAPIResponse, result: OpenAIUsageData): void {
133
+ if (!data.data || !Array.isArray(data.data)) return;
134
+
135
+ for (const bucket of data.data) {
136
+ if (!bucket.results || !Array.isArray(bucket.results)) continue;
137
+
138
+ for (const item of bucket.results) {
139
+ const inputTokens = item.input_tokens || 0;
140
+ const outputTokens = item.output_tokens || 0;
141
+ const requests = item.num_model_requests || 0;
142
+
143
+ result.inputTokens += inputTokens;
144
+ result.outputTokens += outputTokens;
145
+ result.totalTokens += inputTokens + outputTokens;
146
+ result.requestCount += requests;
147
+
148
+ if (item.model) {
149
+ if (!result.modelBreakdown[item.model]) {
150
+ result.modelBreakdown[item.model] = { inputTokens: 0, outputTokens: 0, requests: 0 };
151
+ }
152
+ result.modelBreakdown[item.model].inputTokens += inputTokens;
153
+ result.modelBreakdown[item.model].outputTokens += outputTokens;
154
+ result.modelBreakdown[item.model].requests += requests;
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Rough per-model cost estimation (USD per MTok) */
161
+ function estimateModelCost(inputTokens: number, outputTokens: number): number {
162
+ // Weighted average across common models: ~$2.50/$10 per MTok
163
+ return (inputTokens * 2.5 + outputTokens * 10) / 1_000_000;
164
+ }
165
+
166
+ /** Collector registration for use in collectors/index.ts COLLECTORS array */
167
+ export const openaiCollector: ExternalCollector<OpenAIUsageData | null> = {
168
+ name: 'openai',
169
+ collect: collectOpenAIUsage,
170
+ defaultValue: null,
171
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Resend Email Usage Collector
3
+ *
4
+ * Collects email usage from the Resend API:
5
+ * - Domain count via GET /domains
6
+ * - Email count via GET /emails (paginated, counts all from response)
7
+ *
8
+ * This is a FLOW metric (emails sent accumulate) - safe to SUM.
9
+ *
10
+ * @see https://resend.com/docs/api-reference/emails/list-emails
11
+ * @see https://resend.com/docs/api-reference/domains/list-domains
12
+ */
13
+
14
+ import type { Env, ResendUsageData } from '../shared';
15
+ import { fetchWithRetry } from '../shared';
16
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
17
+ import type { ExternalCollector } from './index';
18
+
19
+ /**
20
+ * Collect Resend email usage.
21
+ * Fetches domains and recent emails in parallel.
22
+ */
23
+ export async function collectResendUsage(env: Env): Promise<ResendUsageData | null> {
24
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:resend');
25
+ if (!env.RESEND_API_KEY) {
26
+ log.info('No RESEND_API_KEY configured, skipping usage collection');
27
+ return null;
28
+ }
29
+
30
+ const headers = { Authorization: `Bearer ${env.RESEND_API_KEY}` };
31
+
32
+ try {
33
+ const [domainsResponse, emailsResponse] = await Promise.all([
34
+ fetchWithRetry('https://api.resend.com/domains', { headers }),
35
+ fetchWithRetry('https://api.resend.com/emails', { headers }),
36
+ ]);
37
+
38
+ if (!domainsResponse.ok) {
39
+ const errorText = await domainsResponse.text();
40
+ log.error(`Domains API returned ${domainsResponse.status}: ${errorText}`);
41
+ return null;
42
+ }
43
+
44
+ const domainsData = (await domainsResponse.json()) as {
45
+ data?: Array<{ id: string; name: string; status: string }>;
46
+ };
47
+
48
+ let emailsSent = 0;
49
+ if (emailsResponse.ok) {
50
+ const emailsData = (await emailsResponse.json()) as {
51
+ data?: Array<{ id: string; created_at: string }>;
52
+ };
53
+ emailsSent = emailsData.data?.length ?? 0;
54
+ } else {
55
+ log.info('Emails API not available, defaulting to 0', { status: emailsResponse.status });
56
+ }
57
+
58
+ const result: ResendUsageData = {
59
+ emailsSent,
60
+ domainsCount: domainsData.data?.length || 0,
61
+ };
62
+
63
+ log.info('Collected usage', {
64
+ emailsSent: result.emailsSent,
65
+ domainsCount: result.domainsCount,
66
+ });
67
+ return result;
68
+ } catch (error) {
69
+ log.error('Failed to collect usage', error);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Collector registration for use in collectors/index.ts COLLECTORS array */
75
+ export const resendCollector: ExternalCollector<ResendUsageData | null> = {
76
+ name: 'resend',
77
+ collect: collectResendUsage,
78
+ defaultValue: null,
79
+ };
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Stripe Revenue Collector
3
+ *
4
+ * Collects subscription revenue data from the Stripe REST API.
5
+ * Tracks MRR, ARR, active/churned/trial subscriptions, and failed payments.
6
+ *
7
+ * Requires STRIPE_SECRET_KEY (sk_live_... or sk_test_...).
8
+ *
9
+ * This is a FLOW metric (revenue accumulates) - safe to SUM.
10
+ *
11
+ * @see https://docs.stripe.com/api/subscriptions/list
12
+ * @see https://docs.stripe.com/api/invoices/list
13
+ */
14
+
15
+ import type { Env, StripeRevenueData } from '../shared';
16
+ import { fetchWithRetry } from '../shared';
17
+ import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
18
+ import type { ExternalCollector } from './index';
19
+
20
+ /** Maximum pages to fetch to prevent runaway pagination */
21
+ const MAX_PAGES = 10;
22
+
23
+ /** Stripe subscription from the API */
24
+ interface StripeSubscription {
25
+ id: string;
26
+ status: string;
27
+ canceled_at?: number | null;
28
+ items?: {
29
+ data?: Array<{
30
+ plan?: {
31
+ amount?: number;
32
+ interval?: string;
33
+ interval_count?: number;
34
+ };
35
+ }>;
36
+ };
37
+ }
38
+
39
+ /** Stripe list response */
40
+ interface StripeListResponse<T> {
41
+ data?: T[];
42
+ has_more?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Fetch a paginated Stripe endpoint with safety limit.
47
+ */
48
+ async function fetchStripeList<T>(
49
+ url: string,
50
+ headers: Record<string, string>,
51
+ log: ReturnType<typeof createLoggerFromEnv>
52
+ ): Promise<T[]> {
53
+ const items: T[] = [];
54
+ let currentUrl = url;
55
+ let page = 0;
56
+
57
+ while (page < MAX_PAGES) {
58
+ const response = await fetchWithRetry(currentUrl, { headers });
59
+ if (!response.ok) {
60
+ const errorText = await response.text();
61
+ log.error(`Stripe API returned ${response.status}: ${errorText}`);
62
+ break;
63
+ }
64
+
65
+ const data = (await response.json()) as StripeListResponse<T>;
66
+ if (data.data) {
67
+ items.push(...data.data);
68
+ }
69
+
70
+ if (!data.has_more || !data.data?.length) break;
71
+
72
+ // Use the last item's ID for cursor-based pagination
73
+ const lastItem = data.data[data.data.length - 1] as { id?: string };
74
+ if (!lastItem?.id) break;
75
+ const separator = currentUrl.includes('?') ? '&' : '?';
76
+ currentUrl = `${url}${separator}starting_after=${lastItem.id}`;
77
+ page++;
78
+ }
79
+
80
+ return items;
81
+ }
82
+
83
+ /**
84
+ * Calculate monthly amount from a subscription's plan.
85
+ * Normalises yearly/quarterly plans to monthly equivalent.
86
+ */
87
+ function monthlyAmount(sub: StripeSubscription): number {
88
+ let total = 0;
89
+ for (const item of sub.items?.data || []) {
90
+ const amount = item.plan?.amount || 0;
91
+ const interval = item.plan?.interval || 'month';
92
+ const count = item.plan?.interval_count || 1;
93
+
94
+ // Normalise to monthly — Stripe amounts are in cents
95
+ if (interval === 'year') {
96
+ total += amount / (12 * count);
97
+ } else if (interval === 'week') {
98
+ total += (amount * 52) / (12 * count);
99
+ } else if (interval === 'day') {
100
+ total += (amount * 365) / (12 * count);
101
+ } else {
102
+ // month
103
+ total += amount / count;
104
+ }
105
+ }
106
+ return total / 100; // Convert cents to USD
107
+ }
108
+
109
+ /**
110
+ * Collect Stripe subscription revenue data.
111
+ */
112
+ export async function collectStripeRevenue(env: Env): Promise<StripeRevenueData | null> {
113
+ const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:stripe');
114
+ if (!env.STRIPE_SECRET_KEY) {
115
+ log.info('No STRIPE_SECRET_KEY configured, skipping revenue collection');
116
+ return null;
117
+ }
118
+
119
+ try {
120
+ const headers = {
121
+ Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
122
+ 'Content-Type': 'application/x-www-form-urlencoded',
123
+ };
124
+
125
+ // Fetch active subscriptions
126
+ const subscriptions = await fetchStripeList<StripeSubscription>(
127
+ 'https://api.stripe.com/v1/subscriptions?limit=100&status=all',
128
+ headers,
129
+ log
130
+ );
131
+
132
+ let mrr = 0;
133
+ let activeSubscriptions = 0;
134
+ let churnedSubscriptions = 0;
135
+ let trialSubscriptions = 0;
136
+
137
+ const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
138
+
139
+ for (const sub of subscriptions) {
140
+ if (sub.status === 'active') {
141
+ activeSubscriptions++;
142
+ mrr += monthlyAmount(sub);
143
+ } else if (sub.status === 'trialing') {
144
+ trialSubscriptions++;
145
+ } else if (
146
+ sub.status === 'canceled' &&
147
+ sub.canceled_at &&
148
+ sub.canceled_at >= thirtyDaysAgo
149
+ ) {
150
+ churnedSubscriptions++;
151
+ }
152
+ }
153
+
154
+ // Count failed payments (open invoices that are past due)
155
+ const failedInvoices = await fetchStripeList<{ id: string }>(
156
+ 'https://api.stripe.com/v1/invoices?limit=100&status=open',
157
+ headers,
158
+ log
159
+ );
160
+ const failedPayments = failedInvoices.length;
161
+
162
+ const result: StripeRevenueData = {
163
+ mrr,
164
+ arr: mrr * 12,
165
+ activeSubscriptions,
166
+ churnedSubscriptions,
167
+ failedPayments,
168
+ trialSubscriptions,
169
+ totalRevenue: mrr, // Current month snapshot
170
+ currency: 'usd',
171
+ };
172
+
173
+ log.info('Collected revenue', {
174
+ mrr: result.mrr,
175
+ arr: result.arr,
176
+ activeSubscriptions: result.activeSubscriptions,
177
+ churnedSubscriptions: result.churnedSubscriptions,
178
+ });
179
+
180
+ return result;
181
+ } catch (error) {
182
+ log.error('Failed to collect revenue', error);
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /** Collector registration for use in collectors/index.ts COLLECTORS array */
188
+ export const stripeCollector: ExternalCollector<StripeRevenueData | null> = {
189
+ name: 'stripe',
190
+ collect: collectStripeRevenue,
191
+ defaultValue: null,
192
+ };
@@ -60,6 +60,10 @@ export interface Env {
60
60
  MINIMAX_API_KEY?: string;
61
61
  GCP_PROJECT_ID?: string;
62
62
  GCP_SERVICE_ACCOUNT_JSON?: string; // JSON string for service account credentials
63
+ // Stripe
64
+ STRIPE_SECRET_KEY?: string; // Stripe secret key (sk_live_... or sk_test_...)
65
+ // GitHub org name (for billing API — replaces hardcoded org)
66
+ GITHUB_ORG?: string;
63
67
  // Live usage API authentication
64
68
  USAGE_API_KEY?: string;
65
69
  // Platform SDK bindings (pilot integration)
@@ -951,6 +955,48 @@ export interface GeminiUsageData {
951
955
  methodBreakdown: Record<string, number>;
952
956
  }
953
957
 
958
+ /**
959
+ * Stripe revenue data (Flow metric — revenue accumulates)
960
+ * Collected via Stripe REST API
961
+ */
962
+ export interface StripeRevenueData {
963
+ /** Monthly Recurring Revenue in USD */
964
+ mrr: number;
965
+ /** Annual Recurring Revenue (MRR × 12) */
966
+ arr: number;
967
+ /** Number of active subscriptions */
968
+ activeSubscriptions: number;
969
+ /** Subscriptions cancelled in last 30 days */
970
+ churnedSubscriptions: number;
971
+ /** Open/unpaid invoices */
972
+ failedPayments: number;
973
+ /** Subscriptions currently in trial */
974
+ trialSubscriptions: number;
975
+ /** Total revenue collected in period (USD) */
976
+ totalRevenue: number;
977
+ /** Currency (typically 'usd') */
978
+ currency: string;
979
+ }
980
+
981
+ /**
982
+ * Configuration for a custom HTTP collector.
983
+ * Use with `createCustomCollector()` factory in `collectors/custom-http.ts`.
984
+ */
985
+ export interface CustomHttpCollectorConfig {
986
+ /** Unique name for the collector */
987
+ name: string;
988
+ /** API endpoint URL */
989
+ url: string;
990
+ /** Auth type: bearer token, basic auth, or API key header */
991
+ auth: { type: 'bearer'; envKey: string }
992
+ | { type: 'basic'; usernameEnvKey: string; passwordEnvKey: string }
993
+ | { type: 'api-key'; headerName: string; envKey: string };
994
+ /** Map of metric name → JSON path in response (dot notation) */
995
+ metrics: Record<string, { jsonPath: string; unit: string; isCost?: boolean }>;
996
+ /** Optional request headers */
997
+ headers?: Record<string, string>;
998
+ }
999
+
954
1000
  // =============================================================================
955
1001
  // ERROR HANDLING TYPES
956
1002
  // =============================================================================
@@ -1100,16 +1100,106 @@ async function handleScheduled(
1100
1100
  }
1101
1101
 
1102
1102
  // Persist collected external metrics to D1 third_party_usage table.
1103
- // The collector framework returns results keyed by collector name.
1104
- // TODO: Add your own persistence logic for each registered collector.
1105
- // See workers/lib/usage/collectors/example.ts for the collector template.
1103
+ // Uncomment the blocks below for providers you've registered in collectors/index.ts.
1106
1104
  //
1107
- // Example:
1108
- // const myProviderData = externalMetrics.results['my-provider'];
1109
- // if (myProviderData) {
1110
- // await persistThirdPartyUsage(trackedEnv, today, 'my-provider', 'metric_name', value, 'unit', cost);
1111
- // totalD1Writes++;
1105
+ // --- OpenAI (FLOW metric) ---
1106
+ // const openaiData = externalMetrics.results['openai'] as import('./lib/usage/shared').OpenAIUsageData | null;
1107
+ // if (openaiData) {
1108
+ // await persistThirdPartyUsage(trackedEnv, today, 'openai', 'input_tokens', openaiData.inputTokens, 'tokens', 0);
1109
+ // await persistThirdPartyUsage(trackedEnv, today, 'openai', 'output_tokens', openaiData.outputTokens, 'tokens', 0);
1110
+ // await persistThirdPartyUsage(trackedEnv, today, 'openai', 'request_count', openaiData.requestCount, 'requests', 0);
1111
+ // await persistThirdPartyUsage(trackedEnv, today, 'openai', 'estimated_cost', openaiData.totalCost, 'usd', openaiData.totalCost);
1112
+ // for (const [model, usage] of Object.entries(openaiData.modelBreakdown)) {
1113
+ // await persistThirdPartyUsage(trackedEnv, today, 'openai', `model:${model}:input_tokens`, usage.inputTokens, 'tokens', 0);
1114
+ // await persistThirdPartyUsage(trackedEnv, today, 'openai', `model:${model}:output_tokens`, usage.outputTokens, 'tokens', 0);
1112
1115
  // }
1116
+ // totalD1Writes += 4 + Object.keys(openaiData.modelBreakdown).length * 2;
1117
+ // }
1118
+ //
1119
+ // --- Anthropic (FLOW metric) ---
1120
+ // const anthropicData = externalMetrics.results['anthropic'] as import('./lib/usage/shared').AnthropicUsageData | null;
1121
+ // if (anthropicData) {
1122
+ // await persistThirdPartyUsage(trackedEnv, today, 'anthropic', 'input_tokens', anthropicData.inputTokens, 'tokens', 0);
1123
+ // await persistThirdPartyUsage(trackedEnv, today, 'anthropic', 'output_tokens', anthropicData.outputTokens, 'tokens', 0);
1124
+ // await persistThirdPartyUsage(trackedEnv, today, 'anthropic', 'estimated_cost', anthropicData.totalCost, 'usd', anthropicData.totalCost);
1125
+ // for (const [model, usage] of Object.entries(anthropicData.modelBreakdown)) {
1126
+ // await persistThirdPartyUsage(trackedEnv, today, 'anthropic', `model:${model}:input_tokens`, usage.inputTokens, 'tokens', 0);
1127
+ // await persistThirdPartyUsage(trackedEnv, today, 'anthropic', `model:${model}:output_tokens`, usage.outputTokens, 'tokens', 0);
1128
+ // }
1129
+ // totalD1Writes += 3 + Object.keys(anthropicData.modelBreakdown).length * 2;
1130
+ // }
1131
+ //
1132
+ // --- GitHub (FLOW metric) ---
1133
+ // const githubData = externalMetrics.results['github'] as import('./lib/usage/collectors/github').GitHubExternalMetrics | undefined;
1134
+ // if (githubData?.billing) {
1135
+ // const b = githubData.billing;
1136
+ // await persistThirdPartyUsage(trackedEnv, today, 'github', 'actions_minutes', b.actionsMinutes, 'minutes', b.actionsMinutesCost);
1137
+ // await persistThirdPartyUsage(trackedEnv, today, 'github', 'copilot_seats', b.copilotSeats, 'seats', b.copilotCost);
1138
+ // await persistThirdPartyUsage(trackedEnv, today, 'github', 'total_net_cost', b.totalNetCost, 'usd', b.totalNetCost);
1139
+ // totalD1Writes += 3;
1140
+ // }
1141
+ //
1142
+ // --- Stripe (FLOW metric) ---
1143
+ // const stripeData = externalMetrics.results['stripe'] as import('./lib/usage/shared').StripeRevenueData | null;
1144
+ // if (stripeData) {
1145
+ // await persistThirdPartyUsage(trackedEnv, today, 'stripe', 'mrr', stripeData.mrr, 'usd', 0);
1146
+ // await persistThirdPartyUsage(trackedEnv, today, 'stripe', 'arr', stripeData.arr, 'usd', 0);
1147
+ // await persistThirdPartyUsage(trackedEnv, today, 'stripe', 'active_subscriptions', stripeData.activeSubscriptions, 'count', 0);
1148
+ // await persistThirdPartyUsage(trackedEnv, today, 'stripe', 'churned_subscriptions', stripeData.churnedSubscriptions, 'count', 0);
1149
+ // await persistThirdPartyUsage(trackedEnv, today, 'stripe', 'failed_payments', stripeData.failedPayments, 'count', 0);
1150
+ // await persistThirdPartyUsage(trackedEnv, today, 'stripe', 'trial_subscriptions', stripeData.trialSubscriptions, 'count', 0);
1151
+ // await persistThirdPartyUsage(trackedEnv, today, 'stripe', 'total_revenue', stripeData.totalRevenue, 'usd', 0);
1152
+ // totalD1Writes += 7;
1153
+ // }
1154
+ //
1155
+ // --- Apify (FLOW metric) ---
1156
+ // const apifyData = externalMetrics.results['apify'] as import('./lib/usage/shared').ApifyUsageData | null;
1157
+ // if (apifyData) {
1158
+ // await persistThirdPartyUsage(trackedEnv, today, 'apify', 'total_credits_usd', apifyData.totalUsageCreditsUsd, 'usd', apifyData.totalUsageCreditsUsd);
1159
+ // await persistThirdPartyUsage(trackedEnv, today, 'apify', 'actor_compute_units', apifyData.actorComputeUnits, 'units', 0);
1160
+ // await persistThirdPartyUsage(trackedEnv, today, 'apify', 'data_transfer_gb', apifyData.dataTransferGb, 'gb', 0);
1161
+ // await persistThirdPartyUsage(trackedEnv, today, 'apify', 'storage_gb', apifyData.storageGb, 'gb', 0);
1162
+ // totalD1Writes += 4;
1163
+ // }
1164
+ //
1165
+ // --- Resend (FLOW metric) ---
1166
+ // const resendData = externalMetrics.results['resend'] as import('./lib/usage/shared').ResendUsageData | null;
1167
+ // if (resendData) {
1168
+ // await persistThirdPartyUsage(trackedEnv, today, 'resend', 'emails_sent', resendData.emailsSent, 'count', 0);
1169
+ // await persistThirdPartyUsage(trackedEnv, today, 'resend', 'domains_count', resendData.domainsCount, 'count', 0);
1170
+ // totalD1Writes += 2;
1171
+ // }
1172
+ //
1173
+ // --- DeepSeek (STOCK metric — gauge, do NOT SUM) ---
1174
+ // const deepseekData = externalMetrics.results['deepseek'] as import('./lib/usage/shared').DeepSeekBalanceData | null;
1175
+ // if (deepseekData) {
1176
+ // await persistThirdPartyUsage(trackedEnv, today, 'deepseek', 'total_balance', deepseekData.totalBalance, 'usd', 0);
1177
+ // await persistThirdPartyUsage(trackedEnv, today, 'deepseek', 'granted_balance', deepseekData.grantedBalance, 'usd', 0);
1178
+ // await persistThirdPartyUsage(trackedEnv, today, 'deepseek', 'topped_up_balance', deepseekData.toppedUpBalance, 'usd', 0);
1179
+ // await persistThirdPartyUsage(trackedEnv, today, 'deepseek', 'is_available', deepseekData.isAvailable ? 1 : 0, 'boolean', 0);
1180
+ // totalD1Writes += 4;
1181
+ // }
1182
+ //
1183
+ // --- Minimax (STOCK metric — gauge, do NOT SUM) ---
1184
+ // const minimaxData = externalMetrics.results['minimax'] as import('./lib/usage/shared').MinimaxQuotaData | null;
1185
+ // if (minimaxData) {
1186
+ // await persistThirdPartyUsage(trackedEnv, today, 'minimax', 'remaining_quota', minimaxData.remainingQuota, 'units', 0);
1187
+ // await persistThirdPartyUsage(trackedEnv, today, 'minimax', 'total_quota', minimaxData.totalQuota, 'units', 0);
1188
+ // await persistThirdPartyUsage(trackedEnv, today, 'minimax', 'usage_percentage', minimaxData.usagePercentage, 'percent', 0);
1189
+ // totalD1Writes += 3;
1190
+ // }
1191
+ //
1192
+ // --- Gemini (FLOW metric) ---
1193
+ // const geminiData = externalMetrics.results['gemini'] as import('./lib/usage/shared').GeminiUsageData | null;
1194
+ // if (geminiData) {
1195
+ // await persistThirdPartyUsage(trackedEnv, today, 'gemini', 'request_count', geminiData.requestCount, 'requests', 0);
1196
+ // await persistThirdPartyUsage(trackedEnv, today, 'gemini', 'estimated_cost', geminiData.estimatedCostUsd, 'usd', geminiData.estimatedCostUsd);
1197
+ // await persistThirdPartyUsage(trackedEnv, today, 'gemini', 'avg_latency_ms', geminiData.avgLatencyMs, 'ms', 0);
1198
+ // for (const [method, count] of Object.entries(geminiData.methodBreakdown)) {
1199
+ // await persistThirdPartyUsage(trackedEnv, today, 'gemini', `method:${method}`, count, 'requests', 0);
1200
+ // }
1201
+ // totalD1Writes += 3 + Object.keys(geminiData.methodBreakdown).length;
1202
+ // }
1113
1203
 
1114
1204
  // 7b. Collect and persist Cloudflare subscription data (once daily at midnight)
1115
1205
  const cfSubscriptions = await graphql.getAccountSubscriptions();
@@ -0,0 +1,53 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ interface Stats {
4
+ byPriority: Record<string, number>;
5
+ byWorker: Array<{ script_name: string; count: number }>;
6
+ total: number;
7
+ }
8
+
9
+ export function ErrorStats() {
10
+ const [stats, setStats] = useState<Stats | null>(null);
11
+
12
+ useEffect(() => {
13
+ fetch('/api/errors/stats')
14
+ .then(res => res.json())
15
+ .then((data: Stats) => setStats(data))
16
+ .catch(() => {});
17
+ }, []);
18
+
19
+ if (!stats) {
20
+ return (
21
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
22
+ {Array.from({ length: 5 }).map((_, i) => (
23
+ <div key={i} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 animate-pulse">
24
+ <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-8 mb-2" />
25
+ <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-12" />
26
+ </div>
27
+ ))}
28
+ </div>
29
+ );
30
+ }
31
+
32
+ const priorities = ['P0', 'P1', 'P2', 'P3', 'P4'];
33
+ const colours: Record<string, string> = {
34
+ P0: 'text-red-600 dark:text-red-400',
35
+ P1: 'text-orange-600 dark:text-orange-400',
36
+ P2: 'text-yellow-600 dark:text-yellow-400',
37
+ P3: 'text-blue-600 dark:text-blue-400',
38
+ P4: 'text-gray-600 dark:text-gray-400',
39
+ };
40
+
41
+ return (
42
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
43
+ {priorities.map(p => (
44
+ <div key={p} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
45
+ <div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">{p}</div>
46
+ <div className={`text-2xl font-bold mt-1 ${colours[p]}`}>
47
+ {stats.byPriority[p] ?? 0}
48
+ </div>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ );
53
+ }