Skip to content

Commit c5cc0e5

Browse files
feat(api): surface per-tenant storage row-counts on the operator dashboard
The account-wide D1 storage cap already has alerting (src/selfhost/d1-size-probe.ts, 70%/90% Prometheus alerts shipped as a direct follow-up to the #3810 incident), but it's table-level only -- no way to attribute usage to a specific tenant. This issue was already re-scoped to that specific gap (see the issue's own narrowing comment). Adds listRowCountByTenantSince (src/db/repositories.ts), a per-installation row-count breakdown of ai_usage_events -- the one high-growth table with a clean installationId column today, same GROUP BY shape as the existing listAiCostByTenantSince. Wires it into the operator dashboard payload and a new "Storage by tenant" UI section, following the exact same pattern PR #7191 established for AI cost by tenant. Empty for self-host, as with its sibling. Closes #4890
1 parent 46b8731 commit c5cc0e5

5 files changed

Lines changed: 155 additions & 1 deletion

File tree

apps/loopover-ui/src/routes/app.operator.test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,59 @@ describe("OperatorDashboard AI cost by tenant (#4916)", () => {
114114
expect(screen.getByText("$2.00")).toBeTruthy();
115115
});
116116
});
117+
118+
describe("OperatorDashboard storage by tenant (#4890)", () => {
119+
function mockDashboard(
120+
storageRowCountByTenant?: Array<{ installationId: string; rowCount: number }>,
121+
) {
122+
useApiResource.mockImplementation((path: string) => {
123+
if (path === "/v1/app/operator-dashboard") {
124+
return {
125+
status: "ready",
126+
data: {
127+
metrics: [{ label: "Installs", value: "12", delta: "+2" }],
128+
noiseReduction: [],
129+
weeklyReport: [],
130+
storageRowCountByTenant,
131+
},
132+
error: null,
133+
loadedAt: "2026-07-17T00:00:00.000Z",
134+
reload: () => {},
135+
};
136+
}
137+
return {
138+
status: "error",
139+
data: null,
140+
error: "unavailable in this test",
141+
errorKind: "unknown",
142+
loadedAt: null,
143+
reload: () => {},
144+
};
145+
});
146+
}
147+
148+
it("renders no section at all when storageRowCountByTenant is absent (self-host, the common case)", () => {
149+
mockDashboard(undefined);
150+
render(<OperatorDashboard />);
151+
expect(screen.queryByText("Storage by tenant")).toBeNull();
152+
});
153+
154+
it("renders no section when storageRowCountByTenant is an empty list", () => {
155+
mockDashboard([]);
156+
render(<OperatorDashboard />);
157+
expect(screen.queryByText("Storage by tenant")).toBeNull();
158+
});
159+
160+
it("renders each tenant's formatted row count, highest-count-first as the backend already ordered them", () => {
161+
mockDashboard([
162+
{ installationId: "inst-2", rowCount: 3000 },
163+
{ installationId: "inst-1", rowCount: 2 },
164+
]);
165+
render(<OperatorDashboard />);
166+
expect(screen.getByText("Storage by tenant")).toBeTruthy();
167+
expect(screen.getByText("inst-2")).toBeTruthy();
168+
expect(screen.getByText("3,000 rows")).toBeTruthy();
169+
expect(screen.getByText("inst-1")).toBeTruthy();
170+
expect(screen.getByText("2 rows")).toBeTruthy();
171+
});
172+
});

apps/loopover-ui/src/routes/app.operator.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type OperatorDashboardResponse = {
3535
};
3636
upstreamDrift?: { status?: string } | null;
3737
aiCostByTenant?: Array<{ installationId: string; totalCostUsd: number }>;
38+
storageRowCountByTenant?: Array<{ installationId: string; rowCount: number }>;
3839
};
3940

4041
type FleetMetrics = {
@@ -53,6 +54,7 @@ type FleetMetrics = {
5354

5455
const formatPct = (v: number | null): string => (v === null ? "—" : `${Math.round(v * 100)}%`);
5556
const usdFmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
57+
const countFmt = new Intl.NumberFormat("en-US");
5658
const formatMs = (v: number | null): string =>
5759
v === null
5860
? "—"
@@ -508,6 +510,31 @@ export function OperatorDashboard() {
508510
</ul>
509511
</section>
510512
) : null}
513+
{data.storageRowCountByTenant && data.storageRowCountByTenant.length > 0 ? (
514+
<section className="rounded-token border border-border bg-transparent p-5">
515+
<h2 className="font-display text-token-lg font-semibold">Storage by tenant</h2>
516+
<p className="mt-1 text-token-xs text-muted-foreground">
517+
AI-usage row count per tenant, highest first — the account-wide D1 storage cap has
518+
its own alert; this is the per-tenant dimension that alert doesn't have. Empty for
519+
self-host.
520+
</p>
521+
<ul className="mt-4 space-y-2">
522+
{data.storageRowCountByTenant.map((tenant) => (
523+
<li
524+
key={tenant.installationId}
525+
className="flex items-center justify-between gap-4 border-b-hairline pb-2 last:border-b-0 last:pb-0"
526+
>
527+
<span className="font-mono text-token-xs text-foreground/90">
528+
{tenant.installationId}
529+
</span>
530+
<span className="font-mono text-token-sm text-foreground">
531+
{countFmt.format(tenant.rowCount)} rows
532+
</span>
533+
</li>
534+
))}
535+
</ul>
536+
</section>
537+
) : null}
511538
<DeadLetterQueuePanel />
512539
<NotificationReadinessCard />
513540
</div>

src/db/repositories.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3645,6 +3645,29 @@ export async function listAiCostByTenantSince(env: Env, sinceIso: string): Promi
36453645
return rows.map((row) => ({ installationId: row.installationId ?? "", totalCostUsd: Number(row.total) }));
36463646
}
36473647

3648+
export type RowCountByTenant = { installationId: string; rowCount: number };
3649+
3650+
/** #4890 (re-scoped): per-installation row-count breakdown of `ai_usage_events` for the operator dashboard --
3651+
* the account-wide D1 storage cap already has alerting (src/selfhost/d1-size-probe.ts,
3652+
* LoopoverD1DatabaseSizeWarning/Critical), but that's table-level only, with no way to attribute usage to a
3653+
* specific tenant. `ai_usage_events` is the one high-growth table with a clean installationId column today
3654+
* (see the sibling per-tenant AI-cost breakdown above); row count is a plain, honest proxy for a tenant's
3655+
* storage footprint here since D1 has no per-row-group byte-size query surface. Same GROUP BY shape as
3656+
* listAiCostByTenantSince -- one query rather than N per-tenant calls, ordered highest-count-first so the
3657+
* dashboard never needs its own client-side sort. */
3658+
export async function listRowCountByTenantSince(env: Env, sinceIso: string): Promise<RowCountByTenant[]> {
3659+
const db = getDb(env.DB);
3660+
const rows = await db
3661+
.select({ installationId: aiUsageEvents.installationId, rowCount: sql<number>`count(*)` })
3662+
.from(aiUsageEvents)
3663+
.where(and(isNotNull(aiUsageEvents.installationId), gte(aiUsageEvents.createdAt, sinceIso)))
3664+
.groupBy(aiUsageEvents.installationId)
3665+
.orderBy(desc(sql`count(*)`));
3666+
/* v8 ignore next -- installationId is the GROUP BY key under an isNotNull filter; D1 cannot return a null
3667+
* group here, so the fallback only guards the driver's own typing, not a real runtime path. */
3668+
return rows.map((row) => ({ installationId: row.installationId ?? "", rowCount: Number(row.rowCount) }));
3669+
}
3670+
36483671
/** Spend-attempt statuses `countByokAiEventsForRepoSince`/`sumByokAiUsageForRepoSince` count: a real request
36493672
* reached the provider, whether or not it returned something usable ("ok") or genuinely failed ("error" --
36503673
* timeout/http_error/exception, see e.g. queue/processors.ts's recordVisualVisionUsage). Deliberately an

src/services/operator-dashboard.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {
1111
listLatestGitHubRateLimitObservations,
1212
listProductUsageDailyRollups,
1313
listRepositories,
14+
listRowCountByTenantSince,
1415
summarizeMcpCompatibilityAdoption,
1516
summarizeProductUsageEvents,
1617
type AiCostByTenant,
18+
type RowCountByTenant,
1719
} from "../db/repositories";
1820
import { getLatestRegistrySnapshot } from "../registry/sync";
1921
import type {
@@ -96,6 +98,10 @@ export type OperatorDashboardPayload = {
9698
// operator (no installation-scoped ai_usage_events rows exist there) -- this surfaces the #7176/#7183 ledger
9799
// data that had no dashboard consumer until now.
98100
aiCostByTenant: AiCostByTenant[];
101+
// #4890 (re-scoped): per-tenant row-count breakdown of ai_usage_events, highest-count-first. The account-wide
102+
// D1 storage cap already has alerting (src/selfhost/d1-size-probe.ts); this is the per-installation dimension
103+
// that alerting doesn't have. Same self-host-always-empty caveat as aiCostByTenant above.
104+
storageRowCountByTenant: RowCountByTenant[];
99105
};
100106

101107
const USAGE_WINDOW_DAYS = 7;
@@ -133,6 +139,7 @@ export async function buildOperatorDashboardPayload(
133139
slopCalibration,
134140
findingAcceptance,
135141
aiCostByTenant,
142+
storageRowCountByTenant,
136143
] = await Promise.all([
137144
listRepositories(env),
138145
listInstallations(env),
@@ -162,6 +169,8 @@ export async function buildOperatorDashboardPayload(
162169
computeFindingAcceptance(env, { days: GATE_ANALYTICS_WINDOW_DAYS, nowMs: Date.now() }),
163170
// #4916: per-tenant AI cost breakdown, same window as the rest of the usage metrics above.
164171
listAiCostByTenantSince(env, usageSince),
172+
// #4890 (re-scoped): per-tenant row-count breakdown, same window as the rest of the usage metrics above.
173+
listRowCountByTenantSince(env, usageSince),
165174
]);
166175
const weeklyValueReport = buildWeeklyValueReport({
167176
generatedAt: nowIso(),
@@ -281,6 +290,7 @@ export async function buildOperatorDashboardPayload(
281290
slopCalibration,
282291
acceptance,
283292
aiCostByTenant,
293+
storageRowCountByTenant,
284294
};
285295
}
286296

test/unit/ai-usage-tenant.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { listAiCostByTenantSince, recordAiUsageEvent, sumAiCostForTenantSince } from "../../src/db/repositories";
2+
import { listAiCostByTenantSince, listRowCountByTenantSince, recordAiUsageEvent, sumAiCostForTenantSince } from "../../src/db/repositories";
33
import { createTestEnv } from "../helpers/d1";
44

55
// #7176: ai_usage_events gained a nullable installation_id tenant column for centralized hosted billing, plus a
@@ -98,3 +98,41 @@ describe("listAiCostByTenantSince (#4916): fleet-wide per-tenant breakdown for t
9898
expect(await listAiCostByTenantSince(env, "2026-07-10T00:00:00.000Z")).toEqual([]);
9999
});
100100
});
101+
102+
describe("listRowCountByTenantSince (#4890): per-tenant storage breakdown for the operator dashboard", () => {
103+
it("groups by tenant, counts correctly, and orders highest-count-first", async () => {
104+
const env = createTestEnv();
105+
const since = "2026-07-10T00:00:00.000Z";
106+
await seedCostEvent(env, "inst-1", 1.25, "2026-07-11T00:00:00.000Z");
107+
await seedCostEvent(env, "inst-1", 0.75, "2026-07-12T00:00:00.000Z"); // inst-1: 2 rows
108+
await seedCostEvent(env, "inst-2", 4.0, "2026-07-13T00:00:00.000Z");
109+
await seedCostEvent(env, "inst-2", 4.0, "2026-07-13T00:00:00.000Z");
110+
await seedCostEvent(env, "inst-2", 4.0, "2026-07-13T00:00:00.000Z"); // inst-2: 3 rows (highest)
111+
await seedCostEvent(env, "inst-3", 0.5, "2026-07-13T00:00:00.000Z"); // inst-3: 1 row (lowest)
112+
113+
const rows = await listRowCountByTenantSince(env, since);
114+
115+
expect(rows).toEqual([
116+
{ installationId: "inst-2", rowCount: 3 },
117+
{ installationId: "inst-1", rowCount: 2 },
118+
{ installationId: "inst-3", rowCount: 1 },
119+
]);
120+
});
121+
122+
it("excludes self-host rows (null installation_id) and rows outside the time window", async () => {
123+
const env = createTestEnv();
124+
const since = "2026-07-10T00:00:00.000Z";
125+
await seedCostEvent(env, "inst-1", 2.0, "2026-07-11T00:00:00.000Z"); // in window
126+
await seedCostEvent(env, "inst-1", 9.0, "2026-07-01T00:00:00.000Z"); // before the window
127+
await seedCostEvent(env, null, 5.0, "2026-07-11T00:00:00.000Z"); // self-host, must never appear
128+
129+
const rows = await listRowCountByTenantSince(env, since);
130+
131+
expect(rows).toEqual([{ installationId: "inst-1", rowCount: 1 }]);
132+
});
133+
134+
it("returns an empty list, not an error, when there are no hosted rows at all (the self-host default)", async () => {
135+
const env = createTestEnv();
136+
expect(await listRowCountByTenantSince(env, "2026-07-10T00:00:00.000Z")).toEqual([]);
137+
});
138+
});

0 commit comments

Comments
 (0)