From 6571f5a1e86118c67361f2cbe021ff4ea2762b03 Mon Sep 17 00:00:00 2001 From: Will Li Date: Fri, 27 Jun 2025 11:10:46 -0700 Subject: [PATCH] add git repo aggregation (#138) Context Currently, our analytics system provides data aggregation by developer. This PR extends this functionality to also provide analytics aggregated by repository name, allowing organizations to better understand which repositories are consuming the most resources. Changes Added a new getRepositoryUsage function in events.ts to aggregate data by repository Created a new Repositories.tsx component to display repository analytics Updated types.ts to add 'repositories' to viewModes and 'repositoryName' as a filter type Enhanced Tasks.tsx to handle filtering by repository name Updated Usage.tsx to render the Repositories component Enhanced TaskCard.tsx to display repository information Added translations for "repositories" in locale files Added empty state handling for when no repository data is available Testing Navigate to Usage page and switch to "Repositories" tab Click on repository names to filter tasks Verify repository information appears in task cards Test with accounts that have no repository data --- apps/web/src/actions/analytics/events.ts | 76 +++++++++++ .../(authenticated)/usage/Repositories.tsx | 125 ++++++++++++++++++ .../src/app/(authenticated)/usage/Tasks.tsx | 15 ++- .../src/app/(authenticated)/usage/Usage.tsx | 3 + .../src/app/(authenticated)/usage/types.ts | 13 +- apps/web/src/components/usage/TaskCard.tsx | 26 ++++ apps/web/src/i18n/locales/en.json | 1 + apps/web/src/i18n/locales/fr.json | 1 + 8 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/usage/Repositories.tsx diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index 6c01b65713..84ededc4b6 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -159,6 +159,21 @@ export type DeveloperUsage = z.infer & { user: User; }; +/** + * Repository usage schema + */ +const repositoryUsageSchema = z.object({ + repositoryName: z.string(), + repositoryUrl: z.string().nullable(), + tasksStarted: z.coerce.number(), + tasksCompleted: z.coerce.number(), + tokens: z.coerce.number(), + cost: z.coerce.number(), + lastEventTimestamp: z.coerce.number(), +}); + +export type RepositoryUsage = z.infer; + export const getDeveloperUsage = async ({ orgId, timePeriod = 90, @@ -223,6 +238,67 @@ export const getDeveloperUsage = async ({ .filter((usage): usage is DeveloperUsage => !!usage.user); }; +/** + * getRepositoryUsage + */ +export const getRepositoryUsage = async ({ + orgId, + timePeriod = 90, + userId, +}: { + orgId?: string | null; + timePeriod?: AnyTimePeriod; + userId?: string | null; +}): Promise => { + await authorizeAnalytics({ + requestedOrgId: orgId, + requestedUserId: userId, + requireAdmin: true, + }); + + if (!orgId) { + return []; + } + + const userFilter = userId ? 'AND userId = {userId: String}' : ''; + const queryParams: Record = { + orgId: orgId!, + timePeriod, + types: [ + TelemetryEventName.TASK_CREATED, + TelemetryEventName.TASK_COMPLETED, + TelemetryEventName.LLM_COMPLETION, + ], + }; + if (userId) { + queryParams.userId = userId; + } + + const results = await analytics.query({ + query: ` + SELECT + repositoryName, + any(repositoryUrl) as repositoryUrl, + SUM(CASE WHEN type = '${TelemetryEventName.TASK_CREATED}' THEN 1 ELSE 0 END) AS tasksStarted, + SUM(CASE WHEN type = '${TelemetryEventName.TASK_COMPLETED}' THEN 1 ELSE 0 END) AS tasksCompleted, + SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN ${tokenSumSql()} ELSE 0 END) AS tokens, + SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost, + MAX(timestamp) AS lastEventTimestamp + FROM events + WHERE orgId = {orgId: String} + AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) + AND type IN ({types: Array(String)}) + AND repositoryName IS NOT NULL + ${userFilter} + GROUP BY repositoryName + `, + format: 'JSONEachRow', + query_params: queryParams, + }); + + return z.array(repositoryUsageSchema).parse(await results.json()); +}; + /** * getModelUsage */ diff --git a/apps/web/src/app/(authenticated)/usage/Repositories.tsx b/apps/web/src/app/(authenticated)/usage/Repositories.tsx new file mode 100644 index 0000000000..c4548f7167 --- /dev/null +++ b/apps/web/src/app/(authenticated)/usage/Repositories.tsx @@ -0,0 +1,125 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useAuth } from '@clerk/nextjs'; +import { formatDistance } from 'date-fns'; +import { ExternalLink } from 'lucide-react'; + +import { type RepositoryUsage, getRepositoryUsage } from '@/actions/analytics'; +import { + formatCurrency, + formatNumber, + formatTimestamp, +} from '@/lib/formatters'; +import { Button, Skeleton } from '@/components/ui'; +import { DataTable } from '@/components/layout'; + +import type { Filter } from './types'; + +export const Repositories = ({ + onFilter, +}: { + onFilter: (filter: Filter) => void; +}) => { + const { orgId } = useAuth(); + + const { data = [], isPending } = useQuery({ + queryKey: ['getRepositoryUsage', orgId], + queryFn: () => getRepositoryUsage({ orgId }), + enabled: !!orgId, + }); + + const cols: ColumnDef[] = useMemo( + () => [ + { + header: 'Repository', + cell: ({ row }) => ( +
+ + {row.original.repositoryUrl && ( + e.stopPropagation()} + className="ml-2 text-muted-foreground hover:text-foreground" + > + + + )} +
+ ), + }, + { + accessorKey: 'tasksStarted', + header: 'Tasks Started', + }, + { + accessorKey: 'tasksCompleted', + header: 'Tasks Completed', + }, + { + header: 'Tokens', + cell: ({ row }) => formatNumber(row.original.tokens), + }, + { + header: 'Cost (USD)', + cell: ({ row }) => formatCurrency(row.original.cost), + }, + { + header: 'Last Event', + cell: ({ row }) => { + const timestamp = row.original.lastEventTimestamp; + if (!timestamp) return 'No activity'; + + const date = new Date(timestamp * 1000); + const relativeTime = formatDistance(date, new Date(), { + addSuffix: true, + }); + const absoluteTime = formatTimestamp(timestamp); + + return ( + + {relativeTime} + + ); + }, + }, + ], + [onFilter], + ); + + const columns = useMemo( + () => + isPending + ? cols.map((col) => ({ + ...col, + cell: () => , + })) + : cols, + [isPending, cols], + ); + + if (data.length === 0 && !isPending) { + return ( +
+ No Git repository data available. Repository information will appear + here once tasks with Git repository context are created. +
+ ); + } + + return ; +}; diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index bb5f406f05..e35e47b5aa 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -44,11 +44,16 @@ export const Tasks = ({ return data; } - return data.filter((task) => - filter.type === 'userId' - ? task.userId === filter.value - : task.model === filter.value, - ); + return data.filter((task) => { + if (filter.type === 'userId') { + return task.userId === filter.value; + } else if (filter.type === 'model') { + return task.model === filter.value; + } else if (filter.type === 'repositoryName') { + return task.repositoryName === filter.value; + } + return false; + }); }, [filter, data]); if (isPending) { diff --git a/apps/web/src/app/(authenticated)/usage/Usage.tsx b/apps/web/src/app/(authenticated)/usage/Usage.tsx index 76c9e70470..02798b622e 100644 --- a/apps/web/src/app/(authenticated)/usage/Usage.tsx +++ b/apps/web/src/app/(authenticated)/usage/Usage.tsx @@ -13,6 +13,7 @@ import { Loading } from '@/components/layout'; import { type Filter, type ViewMode, viewModes } from './types'; import { Developers } from './Developers'; import { Models } from './Models'; +import { Repositories } from './Repositories'; import { Tasks } from './Tasks'; import { TaskModal } from './TaskModal'; @@ -92,6 +93,8 @@ export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => { /> ) : viewMode === 'developers' ? ( + ) : viewMode === 'repositories' ? ( + ) : ( )} diff --git a/apps/web/src/app/(authenticated)/usage/types.ts b/apps/web/src/app/(authenticated)/usage/types.ts index 4df7ba00df..af4263aa0d 100644 --- a/apps/web/src/app/(authenticated)/usage/types.ts +++ b/apps/web/src/app/(authenticated)/usage/types.ts @@ -1,5 +1,14 @@ -export const viewModes = ['developers', 'models', 'tasks'] as const; +export const viewModes = [ + 'developers', + 'models', + 'repositories', + 'tasks', +] as const; export type ViewMode = (typeof viewModes)[number]; -export type Filter = { type: 'userId' | 'model'; value: string; label: string }; +export type Filter = { + type: 'userId' | 'model' | 'repositoryName'; + value: string; + label: string; +}; diff --git a/apps/web/src/components/usage/TaskCard.tsx b/apps/web/src/components/usage/TaskCard.tsx index dcd10bdcd7..f64fde13c2 100644 --- a/apps/web/src/components/usage/TaskCard.tsx +++ b/apps/web/src/components/usage/TaskCard.tsx @@ -106,6 +106,32 @@ export const TaskCard = ({ task, onFilter, onTaskSelected }: TaskCardProps) => { )} + + {/* Repository (if present) */} + {task.repositoryName && ( + <> +
+ + + )} {/* Right side: Tokens and Cost with bullet separator */} diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 34506f966e..dd7aee3565 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -36,6 +36,7 @@ "view_mode_title": "View By", "view_mode_developers": "Developers", "view_mode_models": "Models", + "view_mode_repositories": "Repositories", "view_mode_tasks": "Tasks", "chart_metric_tasks": "Tasks", "chart_metric_tokens": "Tokens", diff --git a/apps/web/src/i18n/locales/fr.json b/apps/web/src/i18n/locales/fr.json index e20ec9e6ce..34386f4df0 100644 --- a/apps/web/src/i18n/locales/fr.json +++ b/apps/web/src/i18n/locales/fr.json @@ -36,6 +36,7 @@ "view_mode_title": "Afficher Par", "view_mode_developers": "Développeurs", "view_mode_models": "Modèles", + "view_mode_repositories": "Dépôts", "view_mode_tasks": "Tâches" } }