feat: request count log per provider, per date (#4009) (#6812)

* feat(dashboard): request count log per provider, per date (#4009)

Some providers bill by request rather than by token, so operators need
a plain per-provider, per-date request count breakdown, not just token
aggregates. Adds a new getProviderDailyUsageRows() aggregation query
(src/lib/db/usageAnalytics.ts), a dedicated GET
/api/usage/requests-by-provider-date route (kept separate from the
frozen /api/usage/analytics route to respect the file-size baseline),
and a sortable, single-date-filterable table on Dashboard -> Analytics.

Closes #4009

* chore(6812): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-10 19:40:18 -03:00
committed by GitHub
parent e8fce67e70
commit 28fcd418a4
53 changed files with 680 additions and 0 deletions

View File

@@ -23,6 +23,7 @@ import {
ServiceTierBreakdown,
ApiKeyFilterDropdown,
CustomRangePicker,
RequestCountByProviderDateTable,
} from "./analytics";
// ============================================================================
@@ -404,6 +405,9 @@ export default function UsageAnalytics() {
{/* Provider Breakdown Table */}
<ProviderTable byProvider={analytics?.byProvider} />
{/* Request Count by Provider & Date — #4009 (some providers bill per-request) */}
<RequestCountByProviderDateTable range={range} />
{/* API Key Table */}
<ApiKeyTable byApiKey={analytics?.byApiKey} />

View File

@@ -0,0 +1,80 @@
"use client";
/**
* RequestCountByProviderDateTable — #4009
*
* Some providers bill by request rather than by token, so operators need a
* plain per-provider, per-date request count (not just token aggregates).
* Self-contained: fetches its own data (via useProviderDailyUsage) from a
* dedicated endpoint so the main analytics route (frozen at the file-size
* baseline) stays untouched. Header + table markup are split into
* RequestCountDateFilter / RequestCountTable to keep this container under
* the max-lines-per-function complexity gate.
*/
import { useCallback, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import Card from "../Card";
import RequestCountDateFilter from "./RequestCountDateFilter";
import RequestCountTable, { type RequestCountSortField } from "./RequestCountTable";
import { useProviderDailyUsage } from "./useProviderDailyUsage";
import { sortProviderDailyUsageRows } from "./requestCountSort";
export default function RequestCountByProviderDateTable({ range }: { range: string }) {
const t = useTranslations("analytics");
const tCommon = useTranslations("common");
const [dateFilter, setDateFilter] = useState("");
const [sortBy, setSortBy] = useState<RequestCountSortField>("date");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const { rows, loading, error } = useProviderDailyUsage(range, dateFilter);
const toggleSort = useCallback(
(field: RequestCountSortField) => {
if (sortBy === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(field);
setSortOrder("desc");
}
},
[sortBy]
);
const sorted = useMemo(
() => sortProviderDailyUsageRows(rows, sortBy, sortOrder),
[rows, sortBy, sortOrder]
);
return (
<Card className="overflow-hidden">
<RequestCountDateFilter
title={t("chartRequestsByProviderDate")}
dateLabel={t("chartDate")}
value={dateFilter}
onChange={setDateFilter}
/>
{loading && rows.length === 0 ? (
<div className="text-center text-text-muted text-sm py-8">{tCommon("loading")}</div>
) : error ? (
<div className="text-center text-red-500 text-sm py-8">
{tCommon("errorShort")}: {error}
</div>
) : sorted.length === 0 ? (
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
) : (
<RequestCountTable
rows={sorted}
sortBy={sortBy}
sortOrder={sortOrder}
onToggleSort={toggleSort}
dateLabel={t("chartDate")}
providerLabel={t("chartProvider")}
requestsLabel={t("chartRequests")}
totalLabel={t("chartTotal")}
/>
)}
</Card>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
/**
* RequestCountDateFilter — title + single-date filter row for #4009's
* request-count-by-provider-date table. Split out to keep the container
* component under the max-lines-per-function complexity gate.
*/
interface RequestCountDateFilterProps {
title: string;
dateLabel: string;
value: string;
onChange: (value: string) => void;
}
export default function RequestCountDateFilter({
title,
dateLabel,
value,
onChange,
}: RequestCountDateFilterProps) {
return (
<div className="p-4 border-b border-border flex items-center justify-between flex-wrap gap-2">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">{title}</h3>
<div className="flex items-center gap-2">
<input
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={dateLabel}
className="px-2 py-1 rounded-md text-xs bg-black/[0.03] dark:bg-white/[0.03] border border-black/5 dark:border-white/5"
/>
{value && (
<button onClick={() => onChange("")} className="text-xs text-text-muted hover:text-text-main">
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
/**
* RequestCountTable — pure presentational table for #4009.
* Split out of RequestCountByProviderDateTable to keep that container under
* the max-lines-per-function complexity gate.
*/
import { PROVIDER_COLORS } from "./chartColors";
import { fmtCompact as fmt, fmtFull } from "@/shared/utils/formatting";
import { SortIndicator } from "./charts";
export interface ProviderDailyUsageRow {
date: string;
provider: string;
requests: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export type RequestCountSortField = "date" | "provider" | "requests" | "totalTokens";
interface RequestCountTableProps {
rows: ProviderDailyUsageRow[];
sortBy: RequestCountSortField;
sortOrder: "asc" | "desc";
onToggleSort: (field: RequestCountSortField) => void;
dateLabel: string;
providerLabel: string;
requestsLabel: string;
totalLabel: string;
}
export default function RequestCountTable({
rows,
sortBy,
sortOrder,
onToggleSort,
dateLabel,
providerLabel,
requestsLabel,
totalLabel,
}: RequestCountTableProps) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
<tr>
<th
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => onToggleSort("date")}
>
{dateLabel} <SortIndicator active={sortBy === "date"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => onToggleSort("provider")}
>
{providerLabel} <SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => onToggleSort("requests")}
>
{requestsLabel} <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => onToggleSort("totalTokens")}
>
{totalLabel} <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row, i) => (
<tr
key={`${row.date}::${row.provider}`}
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2.5 font-mono text-text-muted">{row.date}</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: PROVIDER_COLORS[i % PROVIDER_COLORS.length] }}
/>
<span className="font-medium capitalize">{row.provider}</span>
</div>
</td>
<td className="px-4 py-2.5 text-right font-mono font-semibold">
{fmtFull(row.requests)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
{fmt(row.totalTokens)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -26,3 +26,4 @@ export { DailyTrendChart, ModelOverTimeChart } from "./rechartsUsageCharts";
export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown";
export { default as CustomRangePicker } from "./CustomRangePicker";
export { default as RequestCountByProviderDateTable } from "./RequestCountByProviderDateTable";

View File

@@ -0,0 +1,24 @@
/**
* requestCountSort — pure sort helper for #4009's request-count-by-provider-date table.
* Split out of RequestCountByProviderDateTable to keep that container under the
* max-lines-per-function complexity gate.
*/
import type { ProviderDailyUsageRow, RequestCountSortField } from "./RequestCountTable";
export function sortProviderDailyUsageRows(
rows: ProviderDailyUsageRow[],
sortBy: RequestCountSortField,
sortOrder: "asc" | "desc"
): ProviderDailyUsageRow[] {
const arr = [...rows];
arr.sort((a, b) => {
const va = a[sortBy];
const vb = b[sortBy];
if (typeof va === "string" && typeof vb === "string") {
return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
}
return sortOrder === "asc" ? Number(va) - Number(vb) : Number(vb) - Number(va);
});
return arr;
}

View File

@@ -0,0 +1,46 @@
"use client";
/**
* useProviderDailyUsage — data hook for #4009's request-count-by-provider-date table.
* Split out of RequestCountByProviderDateTable to keep that container under the
* max-lines-per-function complexity gate.
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
import type { ProviderDailyUsageRow } from "./RequestCountTable";
export function useProviderDailyUsage(range: string, dateFilter: string) {
const tCommon = useTranslations("common");
const [rows, setRows] = useState<ProviderDailyUsageRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchRows = useCallback(async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (dateFilter) {
params.set("date", dateFilter);
} else {
params.set("range", range);
}
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
const data = await res.json();
setRows(Array.isArray(data.rows) ? data.rows : []);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [range, dateFilter, tCommon]);
useEffect(() => {
fetchRows();
}, [fetchRows]);
return { rows, loading, error };
}