feat(ui): Spend Per Tag card on the Usage page's Team view

With exactly one team selected on Usage -> Team Usage, a Spend Per Tag card
sits under Spend Per Team and lists that team's spend per tag, highest first,
from /team/daily/activity/aggregated?group_by=tag. It is not shown for zero or
several teams, or in any other view. A caption notes that a request with
several tags counts once per tag, so the rows can add up to more than the
team total, and a failed fetch says so instead of reading as no spend.

DailyActivityRequest gains groupBy, sent as group_by for team reports only.
The per-tag folding lives in teamTagSpend.ts with its own unit tests.

LIT-8516
This commit is contained in:
Mrinal Chanshetty 2026-10-19 20:08:18 -07:00
parent 0bc9e64274
commit 62bc59502c
8 changed files with 349 additions and 1 deletions

View file

@ -616,6 +616,101 @@ describe("EntityUsage", () => {
expect(screen.queryByText(/^team-user-spend:/)).not.toBeInTheDocument();
});
const tagSpendFor = (tags: Record<string, { spend: number; api_requests: number }>) => ({
results: [
{
date: "2025-01-01",
metrics: { ...emptySpendMetrics },
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: Object.fromEntries(
Object.entries(tags).map(([tag, { spend, api_requests }]) => [
tag,
{
metrics: { ...emptySpendMetrics, spend, api_requests, successful_requests: api_requests },
metadata: {},
api_key_breakdown: {},
},
]),
),
},
},
],
metadata: { total_spend: 0, total_api_requests: 0 },
});
it("shows one selected team's spend per tag from a group_by=tag request", async () => {
mockTeamDailyActivityCall.mockImplementation((request: { groupBy?: string }) =>
Promise.resolve(
request.groupBy === "tag"
? tagSpendFor({
"project:rag-v2": { spend: 0.25, api_requests: 1 },
"cost-center:cc-42": { spend: 0.5, api_requests: 2 },
})
: mockSpendData,
),
);
const user = userEvent.setup();
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Team Multi Select" }));
const card = await screen.findByTestId("team-tag-spend-card");
expect(within(card).getByRole("heading", { name: "Spend Per Tag" })).toBeInTheDocument();
const rows = (await within(card).findAllByRole("row")).slice(1);
expect(rows.map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([
"cost-center:cc-42",
"project:rag-v2",
]);
expect(within(rows[0]).getByText("$0.5000")).toBeInTheDocument();
expect(mockTeamDailyActivityCall).toHaveBeenCalledWith(
expect.objectContaining({ entityIds: ["team-1"], groupBy: "tag" }),
);
});
it("asks for tag spend only for exactly one team in the team view", async () => {
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
cleanup();
render(
<EntityUsage
{...defaultProps}
entityType="team"
selectedEntities={["team-1", "team-2"]}
onSelectedEntitiesChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
cleanup();
render(
<EntityUsage
{...defaultProps}
entityType="tag"
selectedEntities={["tag-1"]}
onSelectedEntitiesChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
expect(mockTeamDailyActivityCall).not.toHaveBeenCalledWith(expect.objectContaining({ groupBy: "tag" }));
});
it("should render with organization entity type and call organization API", async () => {
render(<StatefulEntityUsage {...defaultProps} entityType="organization" />);

View file

@ -53,7 +53,9 @@ import {
} from "../overview/overviewData";
import { Panel, Segmented, Sparkline, Stat } from "../overview/Primitives";
import TopModelView from "./TopModelView";
import TeamTagSpendCard from "./TeamTagSpendCard";
import TeamUserSpendCard from "./TeamUserSpendCard";
import { singleTeamId } from "./teamTagSpend";
import { ProviderSpendBreakdown } from "./SpendByProvider";
const BRAND = "#2b3fd6";
@ -361,6 +363,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const filterSlot = entityFilterSlots[entityType];
const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
const tagSpendTeamId = entityType === "team" ? singleTeamId(selectedTags) : null;
const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata);
const showPtuHours = entityType === "team" && hasPtuHours(spendData.metadata);
const userSpendTeamIds = useMemo(
@ -573,6 +576,10 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
/>
</Panel>
{tagSpendTeamId !== null && (
<TeamTagSpendCard accessToken={accessToken} startTime={startTime} endTime={endTime} teamId={tagSpendTeamId} />
)}
{entityType === "team" && (
<TeamUserSpendCard
accessToken={accessToken}

View file

@ -0,0 +1,98 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Tags } from "lucide-react";
import React, { useMemo } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { toDailyData, type DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi";
import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity";
import { Panel } from "../overview/Primitives";
import { ENTITY_API } from "./entityFetchFns";
import { teamTagSpendRows, type TeamTagSpendRow } from "./teamTagSpend";
interface TeamTagSpendCardProps {
accessToken: string | null;
startTime: Date | null;
endTime: Date | null;
teamId: string;
}
const QUIET_HEADER = { headerClassName: "font-normal" };
const columns: ColumnDef<TeamTagSpendRow>[] = [
{
header: "Tag",
accessorKey: "tag",
meta: QUIET_HEADER,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.tag}</span>,
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
},
{
header: "Requests",
accessorKey: "api_requests",
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.api_requests.toLocaleString(),
},
{
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
},
{
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
},
{
header: "Tokens",
accessorKey: "total_tokens",
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.total_tokens.toLocaleString(),
},
];
const TeamTagSpendCard: React.FC<TeamTagSpendCardProps> = ({ accessToken, startTime, endTime, teamId }) => {
const tagSpendRequest: DailyActivityRequest = {
accessToken: accessToken as string,
startTime: startTime as Date,
endTime: endTime as Date,
entityIds: [teamId],
groupBy: "tag",
};
const { data, loading, failed } = useAggregatedDailyActivity({
fetch: () => ENTITY_API.team.aggregated(tagSpendRequest),
enabled: Boolean(accessToken && startTime && endTime),
deps: [accessToken, startTime, endTime, teamId],
});
const rows = useMemo(() => teamTagSpendRows(toDailyData(data)), [data]);
return (
<Panel icon={Tags} title="Spend Per Tag" testId="team-tag-spend-card">
<p className="mb-3 text-xs text-muted-foreground">
A request with several tags counts once per tag, so the rows can add up to more than the team total
</p>
<DataTable
columns={columns}
data={rows}
getRowId={(row) => row.tag}
isLoading={loading}
maxBodyHeight={280}
noDataMessage={
failed ? "Could not load tag spend. Reload the page to try again" : "No tagged spend in this range"
}
size="compact"
/>
</Panel>
);
};
export default TeamTagSpendCard;

View file

@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import type { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import { singleTeamId, teamTagSpendRows } from "./teamTagSpend";
const metrics = (spend: number, api_requests: number, failed_requests = 0): SpendMetrics => ({
spend,
prompt_tokens: 10 * api_requests,
completion_tokens: 5 * api_requests,
total_tokens: 15 * api_requests,
api_requests,
successful_requests: api_requests - failed_requests,
failed_requests,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const entity = (spend: number, api_requests: number, failed_requests = 0): MetricWithMetadata => ({
metrics: metrics(spend, api_requests, failed_requests),
metadata: {},
api_key_breakdown: {},
});
const day = (date: string, entities: Record<string, MetricWithMetadata>): DailyData => ({
date,
metrics: metrics(0, 0),
breakdown: { models: {}, model_groups: {}, mcp_servers: {}, providers: {}, api_keys: {}, entities },
});
describe("teamTagSpendRows", () => {
it("sums each tag across days and orders by spend, highest first", () => {
const rows = teamTagSpendRows([
day("2026-10-18", { "cost-center:cc-42": entity(0.5, 2), "project:rag-v2": entity(0.25, 1) }),
day("2026-10-19", { "cost-center:cc-42": entity(0.25, 1, 1), "env:prod": entity(1, 4) }),
]);
expect(rows).toEqual([
{ tag: "env:prod", spend: 1, api_requests: 4, successful_requests: 4, failed_requests: 0, total_tokens: 60 },
{
tag: "cost-center:cc-42",
spend: 0.75,
api_requests: 3,
successful_requests: 2,
failed_requests: 1,
total_tokens: 45,
},
{
tag: "project:rag-v2",
spend: 0.25,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
total_tokens: 15,
},
]);
});
it("drops tags with no spend and breaks spend ties by tag name", () => {
const rows = teamTagSpendRows([
day("2026-10-19", { "b-tag": entity(0.5, 1), "a-tag": entity(0.5, 1), "free-tag": entity(0, 3) }),
]);
expect(rows.map((row) => row.tag)).toEqual(["a-tag", "b-tag"]);
});
it("returns no rows when the team sent no tagged spend", () => {
expect(teamTagSpendRows([day("2026-10-19", {})])).toEqual([]);
expect(teamTagSpendRows([])).toEqual([]);
});
});
describe("singleTeamId", () => {
it("is the team only when exactly one is selected", () => {
expect(singleTeamId(["team-a"])).toBe("team-a");
expect(singleTeamId([])).toBeNull();
expect(singleTeamId(["team-a", "team-b"])).toBeNull();
});
});

View file

@ -0,0 +1,49 @@
import type { DailyData } from "@/components/UsagePage/types";
export interface TeamTagSpendRow {
tag: string;
spend: number;
api_requests: number;
successful_requests: number;
failed_requests: number;
total_tokens: number;
}
const emptyRow = (tag: string): TeamTagSpendRow => ({
tag,
spend: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
total_tokens: 0,
});
/**
* The Team view's per-tag breakdown, from a group_by=tag report whose
* breakdown.entities are keyed by tag: one row per tag summed across days,
* zero-spend tags dropped, highest spend first (ties by tag name).
*/
export const teamTagSpendRows = (days: readonly DailyData[]): TeamTagSpendRow[] => {
const byTag = new Map<string, TeamTagSpendRow>();
for (const day of days) {
for (const [tag, { metrics }] of Object.entries(day.breakdown.entities ?? {})) {
const row = byTag.get(tag) ?? emptyRow(tag);
const summed: TeamTagSpendRow = {
tag,
spend: row.spend + metrics.spend,
api_requests: row.api_requests + metrics.api_requests,
successful_requests: row.successful_requests + metrics.successful_requests,
failed_requests: row.failed_requests + metrics.failed_requests,
total_tokens: row.total_tokens + metrics.total_tokens,
};
byTag.set(tag, summed);
}
}
return [...byTag.values()]
.filter((row) => row.spend > 0)
.sort((a, b) => b.spend - a.spend || a.tag.localeCompare(b.tag));
};
/** The card answers "what did this team spend per tag", so it needs exactly one team. */
export const singleTeamId = (selectedTeamIds: readonly string[]): string | null =>
selectedTeamIds.length === 1 ? selectedTeamIds[0] : null;

View file

@ -1,4 +1,4 @@
import type { components } from "@/lib/http/schema";
import type { components, operations } from "@/lib/http/schema";
import type { BreakdownMetrics, DailyData, KeyMetadata, KeyMetricWithMetadata, MetricWithMetadata } from "./types";
export type DailyActivityEntity = "user" | "team" | "tag" | "organization" | "customer" | "agent";
@ -26,8 +26,15 @@ export interface DailyActivityRequest {
model?: string | null;
includeCurrentUtcDay?: boolean;
apiKeyLimit?: number;
/** Team reports only: "tag" buckets one team's spend by tag (needs exactly one id in entityIds). */
groupBy?: TeamGroupBy;
}
type TeamAggregatedQuery = NonNullable<
operations["get_team_daily_activity_aggregated_team_daily_activity_aggregated_get"]["parameters"]["query"]
>;
export type TeamGroupBy = NonNullable<TeamAggregatedQuery["group_by"]>;
export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = {
has_more: false,
page: 1,

View file

@ -123,6 +123,18 @@ describe("dailyActivityAggregatedCall", () => {
expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true");
});
it("sends group_by on team reports only, and only when set", async () => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"], groupBy: "tag" }));
await dailyActivityAggregatedCall("team", req({ entityIds: ["team-a"] }));
await dailyActivityAggregatedCall("tag", req({ entityIds: ["tag-a"], groupBy: "tag" }));
expect(requestedUrl(mockFetch, 0).searchParams.get("group_by")).toBe("tag");
expect(requestedUrl(mockFetch, 1).searchParams.has("group_by")).toBe(false);
expect(requestedUrl(mockFetch, 2).searchParams.has("group_by")).toBe(false);
});
it("sends api_key_limit only when provided", async () => {
const mockFetch = captureFetch();

View file

@ -1389,6 +1389,7 @@ const dailyActivityQuery = (
: {}),
timezone: new Date().getTimezoneOffset().toString(),
include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined,
group_by: entity === "team" ? req.groupBy : undefined,
...extra,
};
};