From 84ccb9829816e16bd79077d388710511c701f16a Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Wed, 20 May 2026 21:40:03 +0100 Subject: [PATCH 1/2] feat(insights): add cost breakdown by role to Usage by Employee table Add a group_by=role mode to the searchUsers telemetry endpoint so orgs can view cost aggregated by RBAC role instead of individual employees. The backend fetches per-user costs from ClickHouse, joins with role assignments from Postgres, and returns role-level aggregates. The frontend gains an Employee/Role toggle on the cost table that lazy-loads role data when activated. Co-Authored-By: Claude Opus 4.6 (1M context) --- .speakeasy/out.openapi.yaml | 58 +- .speakeasy/workflow.lock | 2 - .../src/components/observe/InsightsAgents.tsx | 612 +++++++++++++----- client/sdk/.speakeasy/gen.lock | 20 +- client/sdk/src/models/components/index.ts | 1 + .../sdk/src/models/components/rolesummary.ts | 84 +++ .../models/components/searchuserspayload.ts | 24 + .../models/components/searchusersresult.ts | 8 +- server/design/telemetry/design.go | 31 +- server/gen/http/cli/gram/cli.go | 2 +- server/gen/http/openapi3.json | 2 +- server/gen/http/openapi3.yaml | 58 +- server/gen/http/telemetry/client/cli.go | 12 +- .../http/telemetry/client/encode_decode.go | 20 + server/gen/http/telemetry/client/types.go | 80 ++- .../http/telemetry/server/encode_decode.go | 20 + server/gen/http/telemetry/server/types.go | 47 +- server/gen/telemetry/service.go | 26 +- server/internal/organizations/queries.sql | 7 + .../organizations/repo/queries.sql.go | 33 + server/internal/telemetry/impl.go | 137 ++++ 21 files changed, 1093 insertions(+), 191 deletions(-) create mode 100644 client/sdk/src/models/components/rolesummary.ts diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index 09d8c8d3a0..a6c594e9bc 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -34396,6 +34396,50 @@ components: description: Selector constraints. Null means unrestricted. required: - scope + RoleSummary: + type: object + properties: + cost_per_user: + type: number + description: Average cost per user + format: double + role_id: + type: string + description: Role identifier extracted from role URN + total_chats: + type: integer + description: Total chat sessions across all users + format: int64 + total_cost: + type: number + description: Total cost across all users with this role + format: double + total_input_tokens: + type: integer + description: Sum of input tokens across all users + format: int64 + total_output_tokens: + type: integer + description: Sum of output tokens across all users + format: int64 + total_tokens: + type: integer + description: Sum of all tokens across all users + format: int64 + user_count: + type: integer + description: Number of users with this role + format: int64 + description: Aggregated usage summary for a role + required: + - role_id + - user_count + - total_cost + - cost_per_user + - total_input_tokens + - total_output_tokens + - total_tokens + - total_chats ScopeDefinition: type: object properties: @@ -34724,6 +34768,13 @@ components: description: Cursor for pagination (user identifier from last item) filter: $ref: '#/components/schemas/SearchUsersFilter' + group_by: + type: string + description: Grouping dimension for results + default: employee + enum: + - employee + - role limit: type: integer description: Number of items to return (1-1000) @@ -34754,11 +34805,16 @@ components: next_cursor: type: string description: Cursor for next page + roles: + type: array + items: + $ref: '#/components/schemas/RoleSummary' + description: List of role usage summaries (populated when group_by=role) users: type: array items: $ref: '#/components/schemas/UserSummary' - description: List of user usage summaries + description: List of user usage summaries (populated when group_by=employee) description: Result of searching user usage summaries required: - users diff --git a/.speakeasy/workflow.lock b/.speakeasy/workflow.lock index bb8ce248a4..6fc2609558 100644 --- a/.speakeasy/workflow.lock +++ b/.speakeasy/workflow.lock @@ -13,8 +13,6 @@ targets: sourceNamespace: gram-api-description sourceRevisionDigest: sha256:10e9655e0d048e3e0672597f3dc5db71180a21cf1a9c7b72ea0b62d084c9c6a0 sourceBlobDigest: sha256:d44594f9301d7b0d5210c5ab228cc97185b8b1cebf7a425281e2fec42c31e3a1 - codeSamplesNamespace: gram-api-description-typescript-code-samples - codeSamplesRevisionDigest: sha256:e2fe7c9a9ef92384ab50d115750e1a73361794151f6ba2d029a95d77f6a52fb4 workflow: workflowVersion: 1.0.0 speakeasyVersion: pinned diff --git a/client/dashboard/src/components/observe/InsightsAgents.tsx b/client/dashboard/src/components/observe/InsightsAgents.tsx index eeca0f734e..511b709c0d 100644 --- a/client/dashboard/src/components/observe/InsightsAgents.tsx +++ b/client/dashboard/src/components/observe/InsightsAgents.tsx @@ -27,10 +27,11 @@ import type { GetObservabilityOverviewResult, ModelUsage, ProjectSummary, + RoleSummary, TimeSeriesBucket, UserSummary, } from "@gram/client/models/components"; -import { useGramContext, useMembers } from "@gram/client/react-query"; +import { useGramContext, useMembers, useRoles } from "@gram/client/react-query"; import { unwrapAsync } from "@gram/client/types/fp"; import { TimeRangePicker, @@ -175,6 +176,9 @@ export function InsightsAgentsContent() { const [valueMode, setValueMode] = useState("tokens"); const [expandedChart, setExpandedChart] = useState(null); const [clientFilter, setClientFilter] = useState("all"); + const [groupByDimension, setGroupByDimension] = useState<"employee" | "role">( + "employee", + ); const { from, to, timeRangeMs } = useMemo(() => { const range = customRange ?? getPresetRange(dateRange); @@ -191,10 +195,15 @@ export function InsightsAgentsContent() { }, [customRange, customRangeLabel, dateRange]); const { data: membersData, isLoading: membersLoading } = useMembers(); + const { data: rolesData } = useRoles(); const memberMap = useMemo( () => new Map((membersData?.members ?? []).map((m) => [m.id, m])), [membersData], ); + const roleNameById = useMemo( + () => new Map((rolesData?.roles ?? []).map((r) => [r.id, r.name])), + [rolesData], + ); const usersQuery = useQuery({ queryKey: [ @@ -240,7 +249,24 @@ export function InsightsAgentsContent() { throwOnError: false, }); + const roleUsageQuery = useQuery({ + queryKey: [ + "insights", + "agents", + "roleUsage", + from.toISOString(), + to.toISOString(), + ], + queryFn: () => fetchRoleUsage(client, from, to), + enabled: groupByDimension === "role", + throwOnError: false, + }); + const users = useMemo(() => usersQuery.data ?? [], [usersQuery.data]); + const roleUsage = useMemo( + () => roleUsageQuery.data ?? [], + [roleUsageQuery.data], + ); const projectMetrics = projectQuery.data ?? null; const timeSeries = overviewQuery.data?.timeSeries ?? []; @@ -595,8 +621,13 @@ export function InsightsAgentsContent() { c.label)} /> @@ -935,7 +966,10 @@ type SortField = | "cost" | "costPerSession" | "sessions" - | "share"; + | "share" + | "role" + | "userCount" + | "costPerUser"; function SortableHead({ field, @@ -970,16 +1004,27 @@ function SortableHead({ function EmployeeCostTable({ users, + roleUsage, + roleNameById, valueMode, clientFilter, + groupByDimension, + onGroupByChange, + roleUsageLoading, }: { users: EmployeeRow[]; + roleUsage: RoleSummary[]; + roleNameById: Map; valueMode: ValueMode; clientFilter: string; + groupByDimension: "employee" | "role"; + onGroupByChange: (dim: "employee" | "role") => void; + roleUsageLoading: boolean; }) { const PAGE_SIZE = 10; const [page, setPage] = useState(0); const isCost = valueMode === "cost"; + const isRoleView = groupByDimension === "role"; // Default sort follows the value mode; clicking a column overrides const [sortField, setSortField] = useState(null); @@ -995,6 +1040,13 @@ function EmployeeCostTable({ setPage(0); }; + // Reset sort + page when switching views + const handleGroupByChange = (dim: "employee" | "role") => { + setSortField(null); + setPage(0); + onGroupByChange(dim); + }; + const effectiveSortField = sortField ?? (isCost ? "cost" : "totalTokens"); const effectiveSortDir = sortField ? sortDirection : "desc"; @@ -1032,9 +1084,49 @@ function EmployeeCostTable({ }); }, [users, effectiveSortField, effectiveSortDir, isCost]); - const totalPages = Math.ceil(sortedUsers.length / PAGE_SIZE); + const totalRoleCost = useMemo( + () => roleUsage.reduce((sum, r) => sum + r.totalCost, 0), + [roleUsage], + ); + + const sortedRoles = useMemo(() => { + const getValue = (r: RoleSummary): number | string => { + switch (effectiveSortField) { + case "role": + return (roleNameById.get(r.roleId) ?? r.roleId).toLowerCase(); + case "userCount": + return r.userCount; + case "cost": + return r.totalCost; + case "costPerUser": + return r.costPerUser; + case "input": + return r.totalInputTokens; + case "output": + return r.totalOutputTokens; + case "totalTokens": + return r.totalTokens; + case "sessions": + return r.totalChats; + default: + return r.totalCost; + } + }; + return roleUsage.slice().sort((a, b) => { + const va = getValue(a); + const vb = getValue(b); + const cmp = + typeof va === "string" && typeof vb === "string" + ? va.localeCompare(vb) + : (va as number) - (vb as number); + return effectiveSortDir === "asc" ? cmp : -cmp; + }); + }, [roleUsage, roleNameById, effectiveSortField, effectiveSortDir]); + + const items = isRoleView ? sortedRoles : sortedUsers; + const totalPages = Math.ceil(items.length / PAGE_SIZE); const safePage = Math.min(page, Math.max(totalPages - 1, 0)); - const pageUsers = sortedUsers.slice( + const pageItems = items.slice( safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE, ); @@ -1044,190 +1136,347 @@ function EmployeeCostTable({

- {isCost ? "Cost" : "Usage"} by Employee + {isCost ? "Cost" : "Usage"} by {isRoleView ? "Role" : "Employee"}

- {clientFilter !== "all" && + {!isRoleView && + clientFilter !== "all" && `Filtered to ${formatPlatform(clientFilter)} · `} - {sortedUsers.length} employee - {sortedUsers.length !== 1 ? "s" : ""} + {items.length} {isRoleView ? "role" : "employee"} + {items.length !== 1 ? "s" : ""}

+
+ {(["employee", "role"] as const).map((option) => ( + + ))} +
- - - - - Employee - - - Input - - - Output - - - Total Tokens - - - Cost - - - $/Session - - - Sessions - - - Share - - - - - {pageUsers.length > 0 ? ( - pageUsers.map((user) => ( - - -
- - {user.photoUrl ? ( - - ) : null} - - {initials(user.displayName)} - - -
-

- {user.displayName} -

- {user.email ? ( -

- {user.email} -

- ) : null} - {clientFilter === "all" && user.clients.length > 0 && ( -

- {user.clients.join(", ")} -

- )} -
-
-
- - {formatTokens(user.totalInputTokens)} - - - {formatTokens(user.totalOutputTokens)} - - - - {formatTokens(user.totalTokens)} - - - - + + + ) : ( +
+ + + + Role + + + Users + + + Total Cost + + + Cost/User + + + Input Tokens + + + Output Tokens + + + Sessions + + + + + {(pageItems as RoleSummary[]).length > 0 ? ( + (pageItems as RoleSummary[]).map((role) => { + const roleName = + role.roleId === "unassigned" + ? "Unassigned" + : (roleNameById.get(role.roleId) ?? role.roleId); + const costShare = + totalRoleCost > 0 + ? (role.totalCost / totalRoleCost) * 100 + : 0; + return ( + + +
+ + {roleName} + + {role.roleId === "unassigned" && ( + + no role + + )} +
+
+ + {role.userCount.toLocaleString()} + + +
+ + {formatCost(role.totalCost)} + + + {costShare.toFixed(1)}% + +
+
+ + {formatCost(role.costPerUser)} + + + {formatTokens(role.totalInputTokens)} + + + {formatTokens(role.totalOutputTokens)} + + + {role.totalChats.toLocaleString()} + +
+ ); + }) + ) : ( + + - {formatCost(user.totalCost)} - - - - {formatCost(user.costPerSession)} - - - {user.totalChats.toLocaleString()} - - -
-
-
+ No role usage data found for this time range. + + + )} + +
+ ) + ) : ( + + + + + Employee + + + Input + + + Output + + + Total Tokens + + + Cost + + + $/Session + + + Sessions + + + Share + + + + + {(pageItems as EmployeeRow[]).length > 0 ? ( + (pageItems as EmployeeRow[]).map((user) => ( + + +
+ + {user.photoUrl ? ( + + ) : null} + + {initials(user.displayName)} + + +
+

+ {user.displayName} +

+ {user.email ? ( +

+ {user.email} +

+ ) : null} + {clientFilter === "all" && + user.clients.length > 0 && ( +

+ {user.clients.join(", ")} +

+ )} +
- - {(isCost ? user.costShare : user.tokenShare).toFixed(1)} - % +
+ + {formatTokens(user.totalInputTokens)} + + + {formatTokens(user.totalOutputTokens)} + + + + {formatTokens(user.totalTokens)} + + + + + {formatCost(user.totalCost)} - + + + {formatCost(user.costPerSession)} + + + {user.totalChats.toLocaleString()} + + +
+
+
+
+ + {(isCost ? user.costShare : user.tokenShare).toFixed( + 1, + )} + % + +
+ + + )) + ) : ( + + + No employee activity found for this time range. - )) - ) : ( - - - No employee activity found for this time range. - - - )} - -
+ )} + + + )} {totalPages > 1 && (

{safePage * PAGE_SIZE + 1}– - {Math.min((safePage + 1) * PAGE_SIZE, sortedUsers.length)} of{" "} - {sortedUsers.length} + {Math.min((safePage + 1) * PAGE_SIZE, items.length)} of{" "} + {items.length}