@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.
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +232 -2
- package/package.json +1 -1
- package/templates/full/config/audit-targets.yaml +72 -0
- package/templates/full/dashboard/src/components/notifications/NotificationBell.tsx +30 -0
- package/templates/full/dashboard/src/components/notifications/NotificationList.tsx +116 -0
- package/templates/full/dashboard/src/components/notifications/index.ts +2 -0
- package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
- package/templates/full/dashboard/src/components/patterns/PatternStats.tsx +60 -0
- package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
- package/templates/full/dashboard/src/components/patterns/SuggestionsQueue.tsx +115 -0
- package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +5 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
- package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
- package/templates/full/dashboard/src/components/reports/index.ts +2 -0
- package/templates/full/dashboard/src/components/search/SearchModal.tsx +108 -0
- package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
- package/templates/full/dashboard/src/pages/api/notifications/index.ts +47 -0
- package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
- package/templates/full/dashboard/src/pages/api/notifications/unread-count.ts +31 -0
- package/templates/full/dashboard/src/pages/api/patterns/approve.ts +55 -0
- package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
- package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/index.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/reject.ts +54 -0
- package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
- package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
- package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
- package/templates/full/dashboard/src/pages/api/search/index.ts +74 -0
- package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
- package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
- package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
- package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
- package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
- package/templates/full/dashboard/src/pages/notifications.astro +11 -0
- package/templates/full/migrations/008_auditor.sql +99 -0
- package/templates/full/migrations/010_pricing_versions.sql +110 -0
- package/templates/full/migrations/011_multi_account.sql +51 -0
- package/templates/full/scripts/ops/set-kv-pricing.ts +182 -0
- package/templates/full/scripts/ops/universal-backfill.ts +147 -0
- package/templates/full/workers/lib/ai-judge-schema.ts +181 -0
- package/templates/full/workers/lib/auditor/comprehensive-report.ts +407 -0
- package/templates/full/workers/lib/auditor/feature-coverage.ts +348 -0
- package/templates/full/workers/lib/auditor/index.ts +9 -0
- package/templates/full/workers/lib/auditor/types.ts +167 -0
- package/templates/full/workers/platform-auditor.ts +1071 -0
- package/templates/full/wrangler.auditor.jsonc.hbs +75 -0
- package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
- package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
- package/templates/shared/.github/workflows/platform-check.yml.hbs +28 -0
- package/templates/shared/.github/workflows/security.yml +33 -0
- package/templates/shared/config/observability.yaml.hbs +276 -0
- package/templates/shared/contracts/schemas/envelope.v1.schema.json +64 -0
- package/templates/shared/contracts/schemas/error_report.v1.schema.json +65 -0
- package/templates/shared/contracts/types/telemetry-envelope.types.ts +139 -0
- package/templates/shared/dashboard/astro.config.mjs +21 -0
- package/templates/shared/dashboard/package.json.hbs +29 -0
- package/templates/shared/dashboard/src/components/Header.astro +29 -0
- package/templates/shared/dashboard/src/components/Nav.astro.hbs +59 -0
- package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
- package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
- package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
- package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
- package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
- package/templates/shared/dashboard/src/components/overview/ActivityFeed.tsx +134 -0
- package/templates/shared/dashboard/src/components/overview/CostQuadrant.tsx +131 -0
- package/templates/shared/dashboard/src/components/overview/ErrorsQuadrant.tsx +113 -0
- package/templates/shared/dashboard/src/components/overview/HealthQuadrant.tsx +87 -0
- package/templates/shared/dashboard/src/components/overview/MissionControl.tsx +139 -0
- package/templates/shared/dashboard/src/components/resources/AllowanceStatus.tsx +44 -0
- package/templates/shared/dashboard/src/components/resources/CostCentreOverview.tsx +42 -0
- package/templates/shared/dashboard/src/components/resources/ResourceTabs.tsx +69 -0
- package/templates/shared/dashboard/src/components/resources/index.ts +3 -0
- package/templates/shared/dashboard/src/components/settings/SettingsCard.tsx +21 -0
- package/templates/shared/dashboard/src/components/settings/index.ts +1 -0
- package/templates/shared/dashboard/src/components/ui/AlertBanner.tsx +39 -0
- package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
- package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
- package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
- package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/ui/StatusDot.tsx +21 -0
- package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +9 -0
- package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
- package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
- package/templates/shared/dashboard/src/env.d.ts.hbs +34 -0
- package/templates/shared/dashboard/src/layouts/DashboardLayout.astro +37 -0
- package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
- package/templates/shared/dashboard/src/lib/fetch.ts +29 -0
- package/templates/shared/dashboard/src/lib/types.ts +72 -0
- package/templates/shared/dashboard/src/middleware/auth.ts +100 -0
- package/templates/shared/dashboard/src/middleware/index.ts +1 -0
- package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
- package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
- package/templates/shared/dashboard/src/pages/api/overview/summary.ts +311 -0
- package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
- package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
- package/templates/shared/dashboard/src/pages/api/usage/circuit-breakers.ts +44 -0
- package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
- package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
- package/templates/shared/dashboard/src/pages/api/usage/status.ts +42 -0
- package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
- package/templates/shared/dashboard/src/pages/dashboard.astro +11 -0
- package/templates/shared/dashboard/src/pages/index.astro +3 -0
- package/templates/shared/dashboard/src/pages/resources.astro +11 -0
- package/templates/shared/dashboard/src/pages/settings/index.astro +28 -0
- package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
- package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
- package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
- package/templates/shared/dashboard/src/styles/global.css +29 -0
- package/templates/shared/dashboard/tailwind.config.mjs +9 -0
- package/templates/shared/dashboard/tsconfig.json +9 -0
- package/templates/shared/dashboard/wrangler.json.hbs +47 -0
- package/templates/shared/docs/architecture.md +89 -0
- package/templates/shared/docs/post-deploy-runbook.md +126 -0
- package/templates/shared/docs/troubleshooting.md +91 -0
- package/templates/shared/package.json.hbs +17 -1
- package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
- package/templates/shared/scripts/ops/backfill-cloudflare-hourly.ts +473 -0
- package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
- package/templates/shared/scripts/ops/discover-graphql-datasets.ts +482 -0
- package/templates/shared/scripts/ops/reset-budget-state.ts +279 -0
- package/templates/shared/scripts/ops/validate-controls.js +141 -0
- package/templates/shared/scripts/ops/validate-pipeline.ts +237 -0
- package/templates/shared/scripts/ops/verify-account-completeness.ts +236 -0
- package/templates/shared/scripts/validate-schemas.js +61 -0
- package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
- package/templates/shared/tests/helpers/mock-d1.ts +61 -0
- package/templates/shared/tests/helpers/mock-kv.ts +37 -0
- package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
- package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
- package/templates/shared/vitest.config.ts +18 -0
- package/templates/shared/workers/lib/usage/collectors/anthropic.ts +114 -0
- package/templates/shared/workers/lib/usage/collectors/apify.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/custom-http.ts +151 -0
- package/templates/shared/workers/lib/usage/collectors/deepseek.ts +92 -0
- package/templates/shared/workers/lib/usage/collectors/gemini.ts +263 -0
- package/templates/shared/workers/lib/usage/collectors/github.ts +362 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +31 -15
- package/templates/shared/workers/lib/usage/collectors/minimax.ts +106 -0
- package/templates/shared/workers/lib/usage/collectors/openai.ts +171 -0
- package/templates/shared/workers/lib/usage/collectors/resend.ts +79 -0
- package/templates/shared/workers/lib/usage/collectors/stripe.ts +192 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +46 -0
- package/templates/shared/workers/platform-usage.ts +98 -8
- package/templates/standard/dashboard/src/components/errors/ErrorStats.tsx +53 -0
- package/templates/standard/dashboard/src/components/errors/ErrorsTable.tsx +133 -0
- package/templates/standard/dashboard/src/components/errors/index.ts +2 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
- package/templates/standard/dashboard/src/components/health/DlqStatusCard.tsx +52 -0
- package/templates/standard/dashboard/src/components/health/HealthTabs.tsx +86 -0
- package/templates/standard/dashboard/src/components/health/index.ts +4 -0
- package/templates/standard/dashboard/src/lib/errors.ts +28 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
- package/templates/standard/dashboard/src/pages/api/errors/index.ts +58 -0
- package/templates/standard/dashboard/src/pages/api/errors/stats.ts +55 -0
- package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
- package/templates/standard/dashboard/src/pages/api/health/dlq.ts +43 -0
- package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
- package/templates/standard/dashboard/src/pages/errors.astro +13 -0
- package/templates/standard/dashboard/src/pages/health.astro +11 -0
- package/templates/standard/migrations/009_topology_mapper.sql +65 -0
- package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +37 -3
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +32 -1
- package/templates/standard/workers/lib/mapper/attribution-check.ts +339 -0
- package/templates/standard/workers/lib/mapper/index.ts +7 -0
- package/templates/standard/workers/platform-mapper.ts +482 -0
- package/templates/standard/workers/platform-sdk-test-client.ts +125 -0
- package/templates/standard/wrangler.mapper.jsonc.hbs +85 -0
- package/templates/standard/wrangler.sdk-test-client.jsonc.hbs +62 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for Batch Resource Snapshot Persistence
|
|
3
|
+
*
|
|
4
|
+
* Tests the batch D1 insert logic in persistResourceUsageSnapshots():
|
|
5
|
+
* - Rows are batched in groups of 25
|
|
6
|
+
* - Fallback to individual inserts on batch failure
|
|
7
|
+
* - SQL uses ON CONFLICT DO UPDATE
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
11
|
+
import { MockD1Database } from '../../helpers/mock-d1';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Module mocks
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
createLoggerFromEnv: () => ({
|
|
22
|
+
info: vi.fn(),
|
|
23
|
+
warn: vi.fn(),
|
|
24
|
+
error: vi.fn(),
|
|
25
|
+
debug: vi.fn(),
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
import { persistResourceUsageSnapshots } from '../../../workers/lib/usage/scheduled/data-collection';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// BatchCapturingD1 — extends MockD1Database to track batch() calls
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class BatchCapturingD1 extends MockD1Database {
|
|
37
|
+
batchCalls: unknown[][] = [];
|
|
38
|
+
batchShouldFail = false;
|
|
39
|
+
private batchFailCount = 0;
|
|
40
|
+
private batchFailMax = Infinity;
|
|
41
|
+
|
|
42
|
+
failBatchTimes(n: number): void {
|
|
43
|
+
this.batchShouldFail = true;
|
|
44
|
+
this.batchFailMax = n;
|
|
45
|
+
this.batchFailCount = 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async batch(statements: unknown[]): Promise<unknown[]> {
|
|
49
|
+
this.batchCalls.push([...statements]);
|
|
50
|
+
if (this.batchShouldFail && this.batchFailCount < this.batchFailMax) {
|
|
51
|
+
this.batchFailCount++;
|
|
52
|
+
throw new Error('Simulated batch failure');
|
|
53
|
+
}
|
|
54
|
+
return statements.map(() => ({ success: true, results: [] }));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Fixture factory
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function makeWorkerEntry(name: string) {
|
|
63
|
+
return {
|
|
64
|
+
scriptName: name,
|
|
65
|
+
requests: 100,
|
|
66
|
+
errors: 0,
|
|
67
|
+
cpuTimeMs: 50,
|
|
68
|
+
duration50thMs: 1,
|
|
69
|
+
duration99thMs: 5,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeUsageData(workerCount: number) {
|
|
74
|
+
return {
|
|
75
|
+
workers: Array.from({ length: workerCount }, (_, i) => makeWorkerEntry(`worker-${i}`)),
|
|
76
|
+
d1: { reads: 1000, writes: 100 },
|
|
77
|
+
kv: { reads: 500, writes: 50 },
|
|
78
|
+
r2: { reads: 10, writes: 2, storage: 1024 },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ===========================================================================
|
|
83
|
+
// Tests
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
|
|
86
|
+
describe('persistResourceUsageSnapshots', () => {
|
|
87
|
+
it('batches rows in groups of 25', async () => {
|
|
88
|
+
const db = new BatchCapturingD1();
|
|
89
|
+
const usageData = makeUsageData(30);
|
|
90
|
+
|
|
91
|
+
await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
|
|
92
|
+
|
|
93
|
+
// Should have called batch at least once
|
|
94
|
+
expect(db.batchCalls.length).toBeGreaterThanOrEqual(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('falls back to individual inserts on batch failure', async () => {
|
|
98
|
+
const db = new BatchCapturingD1();
|
|
99
|
+
db.failBatchTimes(1);
|
|
100
|
+
// Queue individual run results for fallback
|
|
101
|
+
for (let i = 0; i < 30; i++) {
|
|
102
|
+
db.queueRunResult({ success: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const usageData = makeUsageData(5);
|
|
106
|
+
|
|
107
|
+
await expect(
|
|
108
|
+
persistResourceUsageSnapshots(db as never, usageData as never, 'all')
|
|
109
|
+
).resolves.not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('handles empty usage data', async () => {
|
|
113
|
+
const db = new BatchCapturingD1();
|
|
114
|
+
const usageData = makeUsageData(0);
|
|
115
|
+
|
|
116
|
+
await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
|
|
117
|
+
|
|
118
|
+
// Should not fail with empty data
|
|
119
|
+
expect(db.batchCalls.length).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('uses ON CONFLICT in SQL statements', async () => {
|
|
123
|
+
const db = new BatchCapturingD1();
|
|
124
|
+
const usageData = makeUsageData(1);
|
|
125
|
+
|
|
126
|
+
await persistResourceUsageSnapshots(db as never, usageData as never, 'all');
|
|
127
|
+
|
|
128
|
+
const hasConflict = db.statements.some((s: string) =>
|
|
129
|
+
s.toUpperCase().includes('ON CONFLICT')
|
|
130
|
+
);
|
|
131
|
+
expect(hasConflict).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for Budget Enforcement
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Feature-level budget warnings at 70%/90% thresholds
|
|
6
|
+
* - Circuit breaker trip at 100%
|
|
7
|
+
* - Monthly budget tracking
|
|
8
|
+
* - Dedup via KV BUDGET_WARN: prefix
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { createMockKV } from '../../helpers/mock-kv';
|
|
13
|
+
import { MockD1Database } from '../../helpers/mock-d1';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Module mocks — must be before imports that resolve these modules
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
vi.mock('@littlebearapps/platform-consumer-sdk', async (importOriginal) => {
|
|
20
|
+
const actual = await importOriginal();
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
createLoggerFromEnv: () => ({
|
|
24
|
+
info: vi.fn(),
|
|
25
|
+
warn: vi.fn(),
|
|
26
|
+
error: vi.fn(),
|
|
27
|
+
debug: vi.fn(),
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
vi.mock('../../../workers/lib/circuit-breaker-middleware', () => ({
|
|
33
|
+
CB_STATUS: { CLOSED: 'active', WARNING: 'warning', OPEN: 'paused' },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('../../../workers/lib/platform-settings', () => ({
|
|
37
|
+
getPlatformSettings: vi.fn().mockResolvedValue({}),
|
|
38
|
+
getProjectSetting: vi.fn().mockResolvedValue(null),
|
|
39
|
+
DEFAULT_PLATFORM_SETTINGS: {},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response('ok', { status: 200 }));
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
checkAndUpdateBudgetStatus,
|
|
46
|
+
checkMonthlyBudgets,
|
|
47
|
+
} from '../../../workers/lib/usage/queue/budget-enforcement';
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function createTestEnv(kvData: Record<string, string> = {}, slackUrl?: string) {
|
|
54
|
+
const db = new MockD1Database();
|
|
55
|
+
db.queueRunResult({ success: true });
|
|
56
|
+
db.queueRunResult({ success: true });
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
env: {
|
|
60
|
+
PLATFORM_CACHE: createMockKV(kvData),
|
|
61
|
+
PLATFORM_DB: db,
|
|
62
|
+
SLACK_WEBHOOK_URL: slackUrl,
|
|
63
|
+
NOTIFICATIONS_API: undefined,
|
|
64
|
+
PLATFORM_TELEMETRY: undefined,
|
|
65
|
+
TELEMETRY_QUEUE: undefined,
|
|
66
|
+
} as unknown,
|
|
67
|
+
db,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ===========================================================================
|
|
72
|
+
// Section A: Feature-Level Budget Warnings
|
|
73
|
+
// ===========================================================================
|
|
74
|
+
|
|
75
|
+
describe('checkAndUpdateBudgetStatus', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
78
|
+
mockFetch.mockClear();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('fires 70% warning when metric is between 70-89% of budget', async () => {
|
|
86
|
+
const { env } = createTestEnv(
|
|
87
|
+
{
|
|
88
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
89
|
+
d1_rows_written: 100_000,
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
'https://hooks.slack.com/test'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await checkAndUpdateBudgetStatus(
|
|
96
|
+
'test:api:endpoint',
|
|
97
|
+
{ d1RowsWritten: 72_000 } as never,
|
|
98
|
+
env as never
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const kv = (env as Record<string, unknown>).PLATFORM_CACHE as ReturnType<typeof createMockKV>;
|
|
102
|
+
expect(kv.put).toHaveBeenCalledWith(
|
|
103
|
+
'BUDGET_WARN:test:api:endpoint:d1RowsWritten:70',
|
|
104
|
+
'1',
|
|
105
|
+
{ expirationTtl: 3600 }
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('fires 90% critical warning when metric >= 90% of budget', async () => {
|
|
110
|
+
const { env } = createTestEnv(
|
|
111
|
+
{
|
|
112
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
113
|
+
d1_rows_written: 100_000,
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
'https://hooks.slack.com/test'
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
await checkAndUpdateBudgetStatus(
|
|
120
|
+
'test:api:endpoint',
|
|
121
|
+
{ d1RowsWritten: 92_000 } as never,
|
|
122
|
+
env as never
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const kv = (env as Record<string, unknown>).PLATFORM_CACHE as ReturnType<typeof createMockKV>;
|
|
126
|
+
expect(kv.put).toHaveBeenCalledWith(
|
|
127
|
+
'BUDGET_WARN:test:api:endpoint:d1RowsWritten:90',
|
|
128
|
+
'1',
|
|
129
|
+
{ expirationTtl: 3600 }
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not fire warning when metric is below 70%', async () => {
|
|
134
|
+
const { env } = createTestEnv(
|
|
135
|
+
{
|
|
136
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
137
|
+
d1_rows_written: 100_000,
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
'https://hooks.slack.com/test'
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
await checkAndUpdateBudgetStatus(
|
|
144
|
+
'test:api:endpoint',
|
|
145
|
+
{ d1RowsWritten: 50_000 } as never,
|
|
146
|
+
env as never
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('skips warning when dedup key already exists', async () => {
|
|
153
|
+
const { env } = createTestEnv(
|
|
154
|
+
{
|
|
155
|
+
'CONFIG:FEATURE:test:api:endpoint:BUDGET': JSON.stringify({
|
|
156
|
+
d1_rows_written: 100_000,
|
|
157
|
+
}),
|
|
158
|
+
'BUDGET_WARN:test:api:endpoint:d1RowsWritten:70': '1',
|
|
159
|
+
},
|
|
160
|
+
'https://hooks.slack.com/test'
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await checkAndUpdateBudgetStatus(
|
|
164
|
+
'test:api:endpoint',
|
|
165
|
+
{ d1RowsWritten: 75_000 } as never,
|
|
166
|
+
env as never
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does nothing when no budget config exists', async () => {
|
|
173
|
+
const { env } = createTestEnv({}, 'https://hooks.slack.com/test');
|
|
174
|
+
|
|
175
|
+
await checkAndUpdateBudgetStatus(
|
|
176
|
+
'test:api:endpoint',
|
|
177
|
+
{ d1RowsWritten: 99_000 } as never,
|
|
178
|
+
env as never
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ===========================================================================
|
|
186
|
+
// Section B: Monthly Budget Tracking
|
|
187
|
+
// ===========================================================================
|
|
188
|
+
|
|
189
|
+
describe('checkMonthlyBudgets', () => {
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
192
|
+
mockFetch.mockClear();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
vi.unstubAllGlobals();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('queries daily_usage_rollups for current month', async () => {
|
|
200
|
+
const { env, db } = createTestEnv({});
|
|
201
|
+
db.queueAllResult([]);
|
|
202
|
+
|
|
203
|
+
await checkMonthlyBudgets(env as never);
|
|
204
|
+
|
|
205
|
+
expect(db.statements.some((s: string) => s.includes('daily_usage_rollups'))).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('handles missing tables gracefully', async () => {
|
|
209
|
+
const { env } = createTestEnv({});
|
|
210
|
+
|
|
211
|
+
// Should not throw even with empty DB
|
|
212
|
+
await expect(checkMonthlyBudgets(env as never)).resolves.not.toThrow();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['tests/**/*.test.ts'],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
include: ['workers/**/*.ts'],
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
resolve: {
|
|
14
|
+
alias: {
|
|
15
|
+
'@': './src',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic (Claude) Usage Collector
|
|
3
|
+
*
|
|
4
|
+
* Collects API usage from the Anthropic Admin Usage API.
|
|
5
|
+
* Requires an Admin API key with organization:usage:read scope.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: This only tracks API usage (developer/organization consumption).
|
|
8
|
+
* Claude Max subscriptions (claude.ai consumer product) are NOT tracked here -
|
|
9
|
+
* there is no API to retrieve Claude Max conversation/usage data.
|
|
10
|
+
*
|
|
11
|
+
* This is a FLOW metric (usage that accumulates) - safe to SUM.
|
|
12
|
+
*
|
|
13
|
+
* @see https://docs.anthropic.com/en/api/admin-api
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Env, AnthropicUsageData } from '../shared';
|
|
17
|
+
import { fetchWithRetry } from '../shared';
|
|
18
|
+
import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
|
|
19
|
+
import type { ExternalCollector } from './index';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collect Anthropic (Claude) API usage from the Admin Usage API.
|
|
23
|
+
* Requires an Admin API key with organization:usage:read scope.
|
|
24
|
+
*/
|
|
25
|
+
export async function collectAnthropicUsage(env: Env): Promise<AnthropicUsageData | null> {
|
|
26
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:anthropic');
|
|
27
|
+
if (!env.ANTHROPIC_ADMIN_API_KEY) {
|
|
28
|
+
log.info('No ANTHROPIC_ADMIN_API_KEY configured, skipping usage collection');
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// Get usage for the last 24 hours
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
36
|
+
const startTime = yesterday.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
37
|
+
const endTime = now.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
38
|
+
|
|
39
|
+
const url =
|
|
40
|
+
`https://api.anthropic.com/v1/organizations/usage?` +
|
|
41
|
+
`start_time=${startTime}&end_time=${endTime}&group_by=model`;
|
|
42
|
+
|
|
43
|
+
const response = await fetchWithRetry(url, {
|
|
44
|
+
headers: {
|
|
45
|
+
'anthropic-version': '2023-06-01',
|
|
46
|
+
'x-api-key': env.ANTHROPIC_ADMIN_API_KEY,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const errorText = await response.text();
|
|
52
|
+
log.error(`Usage API returned ${response.status}: ${errorText}`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const data = (await response.json()) as {
|
|
57
|
+
data?: Array<{
|
|
58
|
+
model?: string;
|
|
59
|
+
input_tokens?: number;
|
|
60
|
+
output_tokens?: number;
|
|
61
|
+
}>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!data.data || !Array.isArray(data.data)) {
|
|
65
|
+
log.info('No usage data returned');
|
|
66
|
+
return { inputTokens: 0, outputTokens: 0, totalCost: 0, modelBreakdown: {} };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result: AnthropicUsageData = {
|
|
70
|
+
inputTokens: 0,
|
|
71
|
+
outputTokens: 0,
|
|
72
|
+
totalCost: 0,
|
|
73
|
+
modelBreakdown: {},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Aggregate usage by model
|
|
77
|
+
for (const item of data.data) {
|
|
78
|
+
const inputTokens = item.input_tokens || 0;
|
|
79
|
+
const outputTokens = item.output_tokens || 0;
|
|
80
|
+
result.inputTokens += inputTokens;
|
|
81
|
+
result.outputTokens += outputTokens;
|
|
82
|
+
|
|
83
|
+
if (item.model) {
|
|
84
|
+
if (!result.modelBreakdown[item.model]) {
|
|
85
|
+
result.modelBreakdown[item.model] = { inputTokens: 0, outputTokens: 0 };
|
|
86
|
+
}
|
|
87
|
+
result.modelBreakdown[item.model].inputTokens += inputTokens;
|
|
88
|
+
result.modelBreakdown[item.model].outputTokens += outputTokens;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Estimate cost (rough approximation - actual pricing varies by model)
|
|
93
|
+
// Claude Sonnet: $3/$15 per MTok, Claude Opus: $15/$75 per MTok
|
|
94
|
+
// Using average estimate of $5/$20 per MTok
|
|
95
|
+
result.totalCost = (result.inputTokens * 5 + result.outputTokens * 20) / 1_000_000;
|
|
96
|
+
|
|
97
|
+
log.info('Collected usage', {
|
|
98
|
+
inputTokens: result.inputTokens,
|
|
99
|
+
outputTokens: result.outputTokens,
|
|
100
|
+
estimatedCost: result.totalCost,
|
|
101
|
+
});
|
|
102
|
+
return result;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
log.error('Failed to collect usage', error);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Collector registration for use in collectors/index.ts COLLECTORS array */
|
|
110
|
+
export const anthropicCollector: ExternalCollector<AnthropicUsageData | null> = {
|
|
111
|
+
name: 'anthropic',
|
|
112
|
+
collect: collectAnthropicUsage,
|
|
113
|
+
defaultValue: null,
|
|
114
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apify Usage Collector
|
|
3
|
+
*
|
|
4
|
+
* Collects platform usage from the Apify Monthly Usage API.
|
|
5
|
+
*
|
|
6
|
+
* This is a FLOW metric (usage that accumulates) - safe to SUM.
|
|
7
|
+
*
|
|
8
|
+
* @see https://docs.apify.com/api/v2/users-me-usage-monthly-get
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Env, ApifyUsageData } from '../shared';
|
|
12
|
+
import { fetchWithRetry } from '../shared';
|
|
13
|
+
import { createLoggerFromEnv } from '@littlebearapps/platform-consumer-sdk';
|
|
14
|
+
import type { ExternalCollector } from './index';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Collect Apify platform usage from the Monthly Usage API.
|
|
18
|
+
*/
|
|
19
|
+
export async function collectApifyUsage(env: Env): Promise<ApifyUsageData | null> {
|
|
20
|
+
const log = createLoggerFromEnv(env, 'platform-usage', 'platform:usage:apify');
|
|
21
|
+
if (!env.APIFY_API_KEY) {
|
|
22
|
+
log.info('No APIFY_API_KEY configured, skipping usage collection');
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetchWithRetry('https://api.apify.com/v2/users/me/usage/monthly', {
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Bearer ${env.APIFY_API_KEY}`,
|
|
30
|
+
Accept: 'application/json',
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const errorText = await response.text();
|
|
36
|
+
if (response.status === 401) {
|
|
37
|
+
log.info('API key invalid or expired');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
log.error(`Usage API returned ${response.status}: ${errorText}`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = (await response.json()) as {
|
|
45
|
+
data?: {
|
|
46
|
+
monthlyServiceUsage?: Record<
|
|
47
|
+
string,
|
|
48
|
+
{
|
|
49
|
+
quantity?: number;
|
|
50
|
+
baseAmountUsd?: number;
|
|
51
|
+
}
|
|
52
|
+
>;
|
|
53
|
+
totalUsageCreditsUsdAfterVolumeDiscount?: number;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const result: ApifyUsageData = {
|
|
58
|
+
totalUsageCreditsUsd: data.data?.totalUsageCreditsUsdAfterVolumeDiscount || 0,
|
|
59
|
+
actorComputeUnits: 0,
|
|
60
|
+
dataTransferGb: 0,
|
|
61
|
+
storageGb: 0,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const services = data.data?.monthlyServiceUsage;
|
|
65
|
+
if (services) {
|
|
66
|
+
if (services.ACTOR_COMPUTE_UNITS) {
|
|
67
|
+
result.actorComputeUnits = services.ACTOR_COMPUTE_UNITS.quantity || 0;
|
|
68
|
+
}
|
|
69
|
+
if (services.DATA_TRANSFER_EXTERNAL_GBYTES) {
|
|
70
|
+
result.dataTransferGb = services.DATA_TRANSFER_EXTERNAL_GBYTES.quantity || 0;
|
|
71
|
+
}
|
|
72
|
+
if (services.DATASET_STORAGE_GBYTES) {
|
|
73
|
+
result.storageGb += services.DATASET_STORAGE_GBYTES.quantity || 0;
|
|
74
|
+
}
|
|
75
|
+
if (services.KEY_VALUE_STORE_STORAGE_GBYTES) {
|
|
76
|
+
result.storageGb += services.KEY_VALUE_STORE_STORAGE_GBYTES.quantity || 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log.info('Collected usage', {
|
|
81
|
+
totalUsageCreditsUsd: result.totalUsageCreditsUsd,
|
|
82
|
+
actorComputeUnits: result.actorComputeUnits,
|
|
83
|
+
});
|
|
84
|
+
return result;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
log.error('Failed to collect usage', error);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Collector registration for use in collectors/index.ts COLLECTORS array */
|
|
92
|
+
export const apifyCollector: ExternalCollector<ApifyUsageData | null> = {
|
|
93
|
+
name: 'apify',
|
|
94
|
+
collect: collectApifyUsage,
|
|
95
|
+
defaultValue: null,
|
|
96
|
+
};
|