diff --git a/messages/en-US.json b/messages/en-US.json index 6444dbdd8..166ca8a4b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3397,6 +3397,8 @@ "aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization", "sidebarLogsAi": "AI Session Logs", "commandLogsAi": "AI Session Logs", + "sidebarLogsAiUsage": "AI Usage Analytics", + "commandLogsAiUsage": "AI Usage Analytics", "provider": "Provider", "capability": "Capability", "model": "Model", diff --git a/server/routers/auditLogs/aiUsageAnalyticsShared.ts b/server/routers/auditLogs/aiUsageAnalyticsShared.ts new file mode 100644 index 000000000..95a6f825b --- /dev/null +++ b/server/routers/auditLogs/aiUsageAnalyticsShared.ts @@ -0,0 +1,170 @@ +import { and, eq, gte, lte, or, inArray, sql } from "drizzle-orm"; +import { aiUsageRecords, userOrgRoles, driver, db } from "@server/db"; +import { z } from "zod"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; + +// Cap on how many distinct series a trend chart will plot before collapsing +// the remainder into an "other" bucket - matches the theme's 5 categorical +// chart colors (--chart-1..--chart-5). +export const TOP_N = 5; + +// Same guard used by queryRequestAnalytics/queryAiSessionLog for distinct +// breakdown lists. +export const DISTINCT_LIMIT = 500; + +export const aiUsageAnalyticsFiltersQuery = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => getSevenDaysAgo().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "Start time as ISO date string (defaults to 7 days ago)" + }), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => new Date().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "End time as ISO date string (defaults to current time)" + }), + providerId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + model: z.string().optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + roleId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + userId: z.string().optional() +}); + +export const aiUsageAnalyticsParams = z.object({ + orgId: z.string() +}); + +export const aiUsageAnalyticsCombined = aiUsageAnalyticsFiltersQuery.merge( + aiUsageAnalyticsParams +); + +export type AiUsageAnalyticsQuery = z.infer; + +// A role has no column on aiUsageRecords - it's derived by resolving the +// role's members to userIds first, then filtering on userId. If the role has +// no members we still need the filter to exclude everything rather than be +// silently ignored, hence the sentinel value. +export async function resolveRoleUserIds( + orgId: string, + roleId?: number +): Promise { + if (!roleId) { + return undefined; + } + + const rows = await db + .select({ userId: userOrgRoles.userId }) + .from(userOrgRoles) + .where( + and(eq(userOrgRoles.orgId, orgId), eq(userOrgRoles.roleId, roleId)) + ); + + return rows.length > 0 + ? rows.map((r) => r.userId) + : ["__no_users_in_role__"]; +} + +export function buildAiUsageWhere( + data: AiUsageAnalyticsQuery, + roleUserIds?: string[] +) { + return and( + eq(aiUsageRecords.orgId, data.orgId), + gte(aiUsageRecords.createdAt, data.timeStart), + lte(aiUsageRecords.createdAt, data.timeEnd), + data.providerId + ? eq(aiUsageRecords.providerId, data.providerId) + : undefined, + data.model ? eq(aiUsageRecords.requestedModel, data.model) : undefined, + data.resourceId + ? or( + eq(aiUsageRecords.resourceId, data.resourceId), + eq(aiUsageRecords.siteResourceId, data.resourceId) + ) + : undefined, + data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined, + roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined + ); +} + +// Buckets createdAt (epoch ms) down to a per-day string, dialect-aware, same +// approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts. +export function dayBucketExpr() { + return driver === "pg" + ? sql`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))` + : sql`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`; +} + +export type DailyMetricRow = { + day: string; + key: K; + value: number; +}; + +// Ranks dimension keys by total value and returns the top N. +export function pickTopNKeys( + totals: Map, + n: number = TOP_N +): K[] { + return [...totals.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, n) + .map(([key]) => key); +} + +export interface DayValueRow { + day: string; + [seriesKey: string]: number | string; +} + +// Collapses per-day, per-key rows into a per-day series object, folding +// anything outside `topKeys` into a shared "other" series. +export function bucketTopNPerDay( + rows: DailyMetricRow[], + topKeys: K[] +): DayValueRow[] { + const topSet = new Set(topKeys); + const byDay = new Map>(); + + for (const row of rows) { + const seriesKey = topSet.has(row.key) ? row.key : "other"; + const dayEntry = byDay.get(row.day) ?? {}; + dayEntry[seriesKey] = (dayEntry[seriesKey] ?? 0) + row.value; + byDay.set(row.day, dayEntry); + } + + return [...byDay.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, values]) => ({ day, ...values })); +} diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 51d970360..12735d650 100644 --- a/server/routers/auditLogs/index.ts +++ b/server/routers/auditLogs/index.ts @@ -3,3 +3,8 @@ export * from "./queryRequestAnalytics"; export * from "./exportRequestAuditLog"; export * from "./queryAiSessionLog"; export * from "./exportAiSessionLog"; +export * from "./queryAiUsageFilterOptions"; +export * from "./queryAiUsageOverview"; +export * from "./queryAiUsageProviders"; +export * from "./queryAiUsageResources"; +export * from "./queryAiUsageUsersRoles"; diff --git a/server/routers/auditLogs/queryAiUsageFilterOptions.ts b/server/routers/auditLogs/queryAiUsageFilterOptions.ts new file mode 100644 index 000000000..46cd3199e --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageFilterOptions.ts @@ -0,0 +1,269 @@ +import { + db, + aiUsageRecords, + aiProviders, + resources, + siteResources, + users, + roles, + userOrgRoles +} from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { and, eq, gte, lte, inArray, isNull, not } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; +import { DISTINCT_LIMIT } from "./aiUsageAnalyticsShared"; + +const queryAiUsageFilterOptionsQuery = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => getSevenDaysAgo().toISOString()), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .transform((val) => new Date(val).getTime()) + .prefault(() => new Date().toISOString()) +}); + +const queryAiUsageFilterOptionsParams = z.object({ + orgId: z.string() +}); + +const queryAiUsageFilterOptionsCombined = + queryAiUsageFilterOptionsQuery.merge(queryAiUsageFilterOptionsParams); +type Q = z.infer; + +function sortNamedFilterOptions( + items: T[] +): T[] { + return [...items].sort((a, b) => { + const nameA = a.name ?? ""; + const nameB = b.name ?? ""; + + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + + return a.id - b.id; + }); +} + +async function query(data: Q) { + const baseConditions = and( + eq(aiUsageRecords.orgId, data.orgId), + gte(aiUsageRecords.createdAt, data.timeStart), + lte(aiUsageRecords.createdAt, data.timeEnd) + ); + + const [ + uniqueProviders, + uniqueModels, + uniqueResources, + uniqueSiteResources, + uniqueUsers + ] = await Promise.all([ + db + .selectDistinct({ id: aiUsageRecords.providerId }) + .from(aiUsageRecords) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ model: aiUsageRecords.requestedModel }) + .from(aiUsageRecords) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ id: aiUsageRecords.resourceId }) + .from(aiUsageRecords) + .where(and(baseConditions, not(isNull(aiUsageRecords.resourceId)))) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ id: aiUsageRecords.siteResourceId }) + .from(aiUsageRecords) + .where( + and( + baseConditions, + isNull(aiUsageRecords.resourceId), + not(isNull(aiUsageRecords.siteResourceId)) + ) + ) + .limit(DISTINCT_LIMIT + 1), + db + .selectDistinct({ userId: aiUsageRecords.userId }) + .from(aiUsageRecords) + .where(and(baseConditions, not(isNull(aiUsageRecords.userId)))) + .limit(DISTINCT_LIMIT + 1) + ]); + + const models = uniqueModels + .map((row) => row.model) + .filter((model): model is string => model !== null) + .sort(); + + const providerIds = uniqueProviders + .map((row) => row.id) + .filter((id): id is number => id !== null); + + let providers: Array<{ id: number; name: string | null }> = []; + if (providerIds.length > 0) { + const providerDetails = await db + .select({ providerId: aiProviders.providerId, name: aiProviders.name }) + .from(aiProviders) + .where(inArray(aiProviders.providerId, providerIds)); + + providers = providerDetails.map((p) => ({ id: p.providerId, name: p.name })); + } + + const resourceIds = uniqueResources + .map((row) => row.id) + .filter((id): id is number => id !== null); + const siteResourceIds = uniqueSiteResources + .map((row) => row.id) + .filter((id): id is number => id !== null); + + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; + if (resourceIds.length > 0) { + const resourceDetails = await db + .select({ resourceId: resources.resourceId, name: resources.name }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + resourcesWithNames = resourcesWithNames.concat( + resourceDetails.map((r) => ({ id: r.resourceId, name: r.name })) + ); + } + if (siteResourceIds.length > 0) { + const siteResourceDetails = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + resourcesWithNames = resourcesWithNames.concat( + siteResourceDetails.map((r) => ({ id: r.siteResourceId, name: r.name })) + ); + } + + const userIds = uniqueUsers + .map((row) => row.userId) + .filter((id): id is string => id !== null); + + let userList: Array<{ id: string; email: string | null }> = []; + let roleList: Array<{ id: number; name: string | null }> = []; + if (userIds.length > 0) { + const userDetails = await db + .select({ userId: users.userId, email: users.email }) + .from(users) + .where(inArray(users.userId, userIds)); + userList = userDetails.map((u) => ({ id: u.userId, email: u.email })); + + const roleRows = await db + .select({ roleId: roles.roleId, name: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, data.orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + + const roleMap = new Map(); + for (const r of roleRows) { + roleMap.set(r.roleId, r.name); + } + roleList = [...roleMap.entries()].map(([id, name]) => ({ id, name })); + } + + return { + providers: sortNamedFilterOptions(providers), + resources: sortNamedFilterOptions(resourcesWithNames), + roles: sortNamedFilterOptions(roleList), + users: userList, + models + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/filters", + description: + "Query the distinct filter options (providers, models, resources, roles, users) available for AI usage analytics within a time range", + tags: [OpenAPITags.Logs], + request: { + query: queryAiUsageFilterOptionsQuery, + params: queryAiUsageFilterOptionsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageFilterOptionsResponse = Awaited>; + +export async function queryAiUsageFilterOptions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = queryAiUsageFilterOptionsParams.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage filter options retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageOverview.ts b/server/routers/auditLogs/queryAiUsageOverview.ts new file mode 100644 index 000000000..57ab55539 --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageOverview.ts @@ -0,0 +1,228 @@ +import { db, aiUsageRecords } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { and, count, desc, eq, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + aiUsageAnalyticsCombined, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + + const [totalsRow] = await db + .select({ + requests: count(), + promptTokens: sql`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`, + cacheReadTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`, + cacheWriteTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`, + completionTokens: sql`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`, + reasoningTokens: sql`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`, + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + estimatedRequests: sql`SUM(CASE WHEN ${aiUsageRecords.estimated} THEN 1 ELSE 0 END)` + }) + .from(aiUsageRecords) + .where(baseConditions); + + const dayExpr = dayBucketExpr(); + + const requestsPerDay = await db + .select({ + day: dayExpr.as("day"), + requests: count() + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr) + .orderBy(dayExpr); + + const tokensPerDay = await db + .select({ + day: dayExpr.as("day"), + promptTokens: sql`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`, + cacheReadTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`, + cacheWriteTokens: sql`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`, + completionTokens: sql`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`, + reasoningTokens: sql`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr) + .orderBy(dayExpr); + + const costPerDay = await db + .select({ + day: dayExpr.as("day"), + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr) + .orderBy(dayExpr); + + const modelByDay = await db + .select({ + day: dayExpr.as("day"), + model: aiUsageRecords.requestedModel, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.requestedModel) + .orderBy(dayExpr); + + const modelCostTotals = new Map(); + const modelTokenTotals = new Map(); + for (const row of modelByDay) { + modelCostTotals.set( + row.model, + (modelCostTotals.get(row.model) ?? 0) + row.cost + ); + modelTokenTotals.set( + row.model, + (modelTokenTotals.get(row.model) ?? 0) + row.tokens + ); + } + + const topModelsByCost = pickTopNKeys(modelCostTotals); + const topModelsByTokens = pickTopNKeys(modelTokenTotals); + + const modelCostPerDay = bucketTopNPerDay( + modelByDay.map((r) => ({ day: r.day, key: r.model, value: r.cost })), + topModelsByCost + ); + const modelTokensPerDay = bucketTopNPerDay( + modelByDay.map((r) => ({ day: r.day, key: r.model, value: r.tokens })), + topModelsByTokens + ); + + const topModelsRaw = await db + .select({ + model: aiUsageRecords.requestedModel, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.requestedModel) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (topModelsRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct models. Please narrow your query." + ); + } + + return { + totalRequests: totalsRow.requests, + totalTokens: totalsRow.totalTokens, + totalCost: totalsRow.costUsd, + estimatedPercent: + totalsRow.requests > 0 + ? (totalsRow.estimatedRequests / totalsRow.requests) * 100 + : 0, + tokenBreakdown: { + promptTokens: totalsRow.promptTokens, + cacheReadTokens: totalsRow.cacheReadTokens, + cacheWriteTokens: totalsRow.cacheWriteTokens, + completionTokens: totalsRow.completionTokens, + reasoningTokens: totalsRow.reasoningTokens + }, + requestsPerDay, + tokensPerDay, + costPerDay, + modelCostPerDay, + modelTokensPerDay, + topModels: topModelsRaw + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/overview", + description: "Query the AI usage analytics overview for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageOverviewResponse = Awaited>; + +export async function queryAiUsageOverview( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage overview retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageProviders.ts b/server/routers/auditLogs/queryAiUsageProviders.ts new file mode 100644 index 000000000..77df6330d --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageProviders.ts @@ -0,0 +1,183 @@ +import { db, aiUsageRecords, aiProviders } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { count, desc, inArray, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + const dayExpr = dayBucketExpr(); + + const providerByDay = await db + .select({ + day: dayExpr.as("day"), + providerId: aiUsageRecords.providerId, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.providerId) + .orderBy(dayExpr); + + const costTotals = new Map(); + const tokenTotals = new Map(); + for (const row of providerByDay) { + const key = String(row.providerId); + costTotals.set(key, (costTotals.get(key) ?? 0) + row.cost); + tokenTotals.set(key, (tokenTotals.get(key) ?? 0) + row.tokens); + } + + const topByCost = pickTopNKeys(costTotals); + const topByTokens = pickTopNKeys(tokenTotals); + + const providerCostPerDay = bucketTopNPerDay( + providerByDay.map((r) => ({ + day: r.day, + key: String(r.providerId), + value: r.cost + })), + topByCost + ); + const providerTokensPerDay = bucketTopNPerDay( + providerByDay.map((r) => ({ + day: r.day, + key: String(r.providerId), + value: r.tokens + })), + topByTokens + ); + + const topProvidersRaw = await db + .select({ + providerId: aiUsageRecords.providerId, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.providerId) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (topProvidersRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct providers. Please narrow your query." + ); + } + + const providerIds = topProvidersRaw.map((r) => r.providerId); + const nameMap = new Map(); + if (providerIds.length > 0) { + const providerDetails = await db + .select({ providerId: aiProviders.providerId, name: aiProviders.name }) + .from(aiProviders) + .where(inArray(aiProviders.providerId, providerIds)); + for (const p of providerDetails) { + nameMap.set(p.providerId, p.name); + } + } + + const topProviders = topProvidersRaw.map((r) => ({ + providerId: r.providerId, + name: nameMap.get(r.providerId) ?? null, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + return { + providerCostPerDay, + providerTokensPerDay, + topProviders + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/providers", + description: "Query the AI usage analytics provider breakdown for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageProvidersResponse = Awaited>; + +export async function queryAiUsageProviders( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage provider breakdown retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageResources.ts b/server/routers/auditLogs/queryAiUsageResources.ts new file mode 100644 index 000000000..fb2fddbc4 --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageResources.ts @@ -0,0 +1,222 @@ +import { db, aiUsageRecords, resources, siteResources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { count, desc, inArray, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +// Composite key namespacing resourceId ("r:") vs siteResourceId ("s:") since +// the two id spaces are independent and can overlap numerically. +function resourceKey(resourceId: number | null, siteResourceId: number | null) { + if (resourceId != null) return `r:${resourceId}`; + if (siteResourceId != null) return `s:${siteResourceId}`; + return "none"; +} + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + const dayExpr = dayBucketExpr(); + + const resourceByDay = await db + .select({ + day: dayExpr.as("day"), + resourceId: aiUsageRecords.resourceId, + siteResourceId: aiUsageRecords.siteResourceId, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.resourceId, aiUsageRecords.siteResourceId) + .orderBy(dayExpr); + + const costTotals = new Map(); + const tokenTotals = new Map(); + for (const row of resourceByDay) { + const key = resourceKey(row.resourceId, row.siteResourceId); + costTotals.set(key, (costTotals.get(key) ?? 0) + row.cost); + tokenTotals.set(key, (tokenTotals.get(key) ?? 0) + row.tokens); + } + + const topByCost = pickTopNKeys(costTotals); + const topByTokens = pickTopNKeys(tokenTotals); + + const resourceCostPerDay = bucketTopNPerDay( + resourceByDay.map((r) => ({ + day: r.day, + key: resourceKey(r.resourceId, r.siteResourceId), + value: r.cost + })), + topByCost + ); + const resourceTokensPerDay = bucketTopNPerDay( + resourceByDay.map((r) => ({ + day: r.day, + key: resourceKey(r.resourceId, r.siteResourceId), + value: r.tokens + })), + topByTokens + ); + + const topResourcesRaw = await db + .select({ + resourceId: aiUsageRecords.resourceId, + siteResourceId: aiUsageRecords.siteResourceId, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.resourceId, aiUsageRecords.siteResourceId) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (topResourcesRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct resources. Please narrow your query." + ); + } + + const resourceIds = topResourcesRaw + .map((r) => r.resourceId) + .filter((id): id is number => id !== null); + const siteResourceIds = topResourcesRaw + .map((r) => r.siteResourceId) + .filter((id): id is number => id !== null); + + const nameMap = new Map(); + if (resourceIds.length > 0) { + const resourceDetails = await db + .select({ resourceId: resources.resourceId, name: resources.name }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + for (const r of resourceDetails) { + nameMap.set(`r:${r.resourceId}`, r.name); + } + } + if (siteResourceIds.length > 0) { + const siteResourceDetails = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + for (const r of siteResourceDetails) { + nameMap.set(`s:${r.siteResourceId}`, r.name); + } + } + + const topResources = topResourcesRaw.map((r) => { + const key = resourceKey(r.resourceId, r.siteResourceId); + return { + key, + resourceId: r.resourceId, + siteResourceId: r.siteResourceId, + type: + r.resourceId != null + ? ("public" as const) + : r.siteResourceId != null + ? ("site" as const) + : null, + name: nameMap.get(key) ?? null, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + }; + }); + + return { + resourceCostPerDay, + resourceTokensPerDay, + topResources + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/resources", + description: "Query the AI usage analytics resource breakdown for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageResourcesResponse = Awaited>; + +export async function queryAiUsageResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage resource breakdown retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/queryAiUsageUsersRoles.ts b/server/routers/auditLogs/queryAiUsageUsersRoles.ts new file mode 100644 index 000000000..b98d1d847 --- /dev/null +++ b/server/routers/auditLogs/queryAiUsageUsersRoles.ts @@ -0,0 +1,271 @@ +import { db, aiUsageRecords, users, roles, userOrgRoles } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { and, count, desc, eq, inArray, sql } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { + aiUsageAnalyticsFiltersQuery, + aiUsageAnalyticsParams, + buildAiUsageWhere, + resolveRoleUserIds, + dayBucketExpr, + pickTopNKeys, + bucketTopNPerDay, + DISTINCT_LIMIT, + type AiUsageAnalyticsQuery +} from "./aiUsageAnalyticsShared"; + +type Q = AiUsageAnalyticsQuery; + +const UNKNOWN_USER_KEY = "unknown"; + +async function query(data: Q) { + const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId); + const baseConditions = buildAiUsageWhere(data, roleUserIds); + const dayExpr = dayBucketExpr(); + + // Per (day, user) is the common granularity both the user trend charts and + // the role trend charts are built from - a usage record only stores + // userId, so role totals are derived by expanding each user's usage into + // every role they hold in the org (per-role double counting for + // multi-role users is expected/accepted). + const userByDay = await db + .select({ + day: dayExpr.as("day"), + userId: aiUsageRecords.userId, + cost: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`, + tokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(dayExpr, aiUsageRecords.userId) + .orderBy(dayExpr); + + const userTotalsRaw = await db + .select({ + userId: aiUsageRecords.userId, + requests: count(), + totalTokens: sql`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`, + costUsd: sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)` + }) + .from(aiUsageRecords) + .where(baseConditions) + .groupBy(aiUsageRecords.userId) + .orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`)) + .limit(DISTINCT_LIMIT + 1); + + if (userTotalsRaw.length > DISTINCT_LIMIT) { + throw createHttpError( + HttpCode.BAD_REQUEST, + "Too many distinct users. Please narrow your query." + ); + } + + const userIds = userTotalsRaw + .map((r) => r.userId) + .filter((id): id is string => id !== null); + + const emailMap = new Map(); + if (userIds.length > 0) { + const userDetails = await db + .select({ userId: users.userId, email: users.email }) + .from(users) + .where(inArray(users.userId, userIds)); + for (const u of userDetails) { + emailMap.set(u.userId, u.email); + } + } + + const topUsers = userTotalsRaw.map((r) => ({ + userId: r.userId, + email: r.userId ? emailMap.get(r.userId) ?? null : null, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + const userCostTotals = new Map(); + const userTokenTotals = new Map(); + for (const row of userByDay) { + const key = row.userId ?? UNKNOWN_USER_KEY; + userCostTotals.set(key, (userCostTotals.get(key) ?? 0) + row.cost); + userTokenTotals.set(key, (userTokenTotals.get(key) ?? 0) + row.tokens); + } + const topUsersByCost = pickTopNKeys(userCostTotals); + const topUsersByTokens = pickTopNKeys(userTokenTotals); + + const userCostPerDay = bucketTopNPerDay( + userByDay.map((r) => ({ + day: r.day, + key: r.userId ?? UNKNOWN_USER_KEY, + value: r.cost + })), + topUsersByCost + ); + const userTokensPerDay = bucketTopNPerDay( + userByDay.map((r) => ({ + day: r.day, + key: r.userId ?? UNKNOWN_USER_KEY, + value: r.tokens + })), + topUsersByTokens + ); + + // Resolve every user's role membership(s) in this org so usage can be + // expanded into per-role totals. + const userToRoles = new Map(); + if (userIds.length > 0) { + const roleRows = await db + .select({ + userId: userOrgRoles.userId, + roleId: roles.roleId, + name: roles.name + }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, data.orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + for (const row of roleRows) { + const existing = userToRoles.get(row.userId) ?? []; + existing.push({ roleId: row.roleId, name: row.name }); + userToRoles.set(row.userId, existing); + } + } + + const roleTotals = new Map< + number, + { name: string | null; requests: number; totalTokens: number; costUsd: number } + >(); + for (const r of userTotalsRaw) { + if (!r.userId) continue; + const userRoles = userToRoles.get(r.userId) ?? []; + for (const role of userRoles) { + const existing = roleTotals.get(role.roleId) ?? { + name: role.name, + requests: 0, + totalTokens: 0, + costUsd: 0 + }; + existing.requests += r.requests; + existing.totalTokens += r.totalTokens; + existing.costUsd += r.costUsd; + roleTotals.set(role.roleId, existing); + } + } + + const topRoles = [...roleTotals.entries()] + .map(([roleId, v]) => ({ roleId, ...v })) + .sort((a, b) => b.costUsd - a.costUsd); + + const roleCostRows: { day: string; key: string; value: number }[] = []; + const roleTokenRows: { day: string; key: string; value: number }[] = []; + for (const row of userByDay) { + if (!row.userId) continue; + const userRoles = userToRoles.get(row.userId) ?? []; + for (const role of userRoles) { + roleCostRows.push({ day: row.day, key: String(role.roleId), value: row.cost }); + roleTokenRows.push({ day: row.day, key: String(role.roleId), value: row.tokens }); + } + } + + const roleCostTotals = new Map(); + const roleTokenTotals = new Map(); + for (const row of roleCostRows) { + roleCostTotals.set(row.key, (roleCostTotals.get(row.key) ?? 0) + row.value); + } + for (const row of roleTokenRows) { + roleTokenTotals.set(row.key, (roleTokenTotals.get(row.key) ?? 0) + row.value); + } + const topRolesByCost = pickTopNKeys(roleCostTotals); + const topRolesByTokens = pickTopNKeys(roleTokenTotals); + + const roleCostPerDay = bucketTopNPerDay(roleCostRows, topRolesByCost); + const roleTokensPerDay = bucketTopNPerDay(roleTokenRows, topRolesByTokens); + + return { + topUsers, + userCostPerDay, + userTokensPerDay, + topRoles, + roleCostPerDay, + roleTokensPerDay + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/ai/usage/users-roles", + description: + "Query the AI usage analytics user and role breakdown for an organization", + tags: [OpenAPITags.Logs], + request: { + query: aiUsageAnalyticsFiltersQuery, + params: aiUsageAnalyticsParams + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export type QueryAiUsageUsersRolesResponse = Awaited>; + +export async function queryAiUsageUsersRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) + ); + } + + const parsedParams = aiUsageAnalyticsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) + ); + } + + const data = await query({ ...parsedQuery.data, ...parsedParams.data }); + + return response(res, { + data, + success: true, + error: false, + message: "AI usage user/role breakdown retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index dd1167d55..b50de6bea 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1502,6 +1502,41 @@ authenticated.get( logs.exportAiSessionLogs ); +authenticated.get( + "/org/:orgId/logs/ai/usage/filters", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageFilterOptions +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/overview", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageOverview +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/providers", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageProviders +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/resources", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageResources +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/users-roles", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageUsersRoles +); + authenticated.get( "/org/:orgId/blueprints", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 87ec88243..02e1ab987 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1545,6 +1545,41 @@ authenticated.get( logs.exportAiSessionLogs ); +authenticated.get( + "/org/:orgId/logs/ai/usage/filters", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageFilterOptions +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/overview", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageOverview +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/providers", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageProviders +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/resources", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageResources +); + +authenticated.get( + "/org/:orgId/logs/ai/usage/users-roles", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + logs.queryAiUsageUsersRoles +); + authenticated.get( "/org/:orgId/logs/analytics", verifyApiKeyOrgAccess, diff --git a/src/app/[orgId]/settings/logs/ai-usage/page.tsx b/src/app/[orgId]/settings/logs/ai-usage/page.tsx new file mode 100644 index 000000000..f045aead0 --- /dev/null +++ b/src/app/[orgId]/settings/logs/ai-usage/page.tsx @@ -0,0 +1,30 @@ +import { AiUsageAnalyticsData } from "@app/components/AiUsageAnalyticsData"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "AI Usage Analytics" +}; + +export interface AiUsageAnalyticsPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function AiUsageAnalyticsPage( + props: AiUsageAnalyticsPageProps +) { + const orgId = (await props.params).orgId; + + return ( + <> + + +
+ +
+ + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 7181fd1c6..6c5e4a935 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -8,6 +8,7 @@ import { Building2, Cable, ChartLine, + Coins, Combine, CreditCard, Fingerprint, @@ -233,6 +234,11 @@ export const orgNavSections = ( href: "/{orgId}/settings/logs/ai", icon: }, + { + title: "sidebarLogsAiUsage", + href: "/{orgId}/settings/logs/ai-usage", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { @@ -532,6 +538,11 @@ export const commandBarNavSections = ( href: "/{orgId}/settings/logs/ai", icon: }, + { + title: "commandLogsAiUsage", + href: "/{orgId}/settings/logs/ai-usage", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { diff --git a/src/components/AiUsageAnalyticsData.tsx b/src/components/AiUsageAnalyticsData.tsx new file mode 100644 index 000000000..573dc96b1 --- /dev/null +++ b/src/components/AiUsageAnalyticsData.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { + aiUsageAnalyticsFiltersSchema, + aiUsageAnalyticsQueries, + type AiUsageAnalyticsFilters +} from "@app/lib/queries"; +import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query"; +import { RefreshCw, XIcon } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { DateRangePicker, type DateTimeValue } from "./DateTimePicker"; +import { Button } from "./ui/button"; +import { Card, CardHeader } from "./ui/card"; +import { Label } from "./ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./ui/select"; +import { Separator } from "./ui/separator"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; +import { HorizontalTabs, type TabItem } from "./HorizontalTabs"; +import { OverviewTab } from "./ai-usage-analytics/OverviewTab"; +import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab"; +import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab"; +import { UsersRolesTab } from "./ai-usage-analytics/UsersRolesTab"; + +export type AiUsageAnalyticsDataProps = { + orgId: string; +}; + +const AI_USAGE_ANALYTICS_QUERY_PREFIX = ["AI_USAGE_ANALYTICS"]; + +export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) { + const searchParams = useSearchParams(); + const path = usePathname(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const filters = aiUsageAnalyticsFiltersSchema.parse( + Object.fromEntries(searchParams.entries()) + ); + + const isEmptySearchParams = Object.values(filters).every( + (v) => v === undefined + ); + + const dateRange = { + startDate: filters.timeStart + ? new Date(filters.timeStart) + : getSevenDaysAgo(), + endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date() + }; + + const { data: filterOptions } = useQuery( + aiUsageAnalyticsQueries.filterOptions({ + orgId: props.orgId, + filters: { + timeStart: filters.timeStart, + timeEnd: filters.timeEnd + } + }) + ); + + const isFetching = + useIsFetching({ + queryKey: [...AI_USAGE_ANALYTICS_QUERY_PREFIX, props.orgId] + }) > 0; + + function setFilter(key: keyof AiUsageAnalyticsFilters, value?: string) { + const newSearch = new URLSearchParams(searchParams); + newSearch.delete(key); + if (value !== undefined) { + newSearch.set(key, value); + } + router.replace(`${path}?${newSearch.toString()}`); + } + + function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) { + const newSearch = new URLSearchParams(searchParams); + const timeRegex = + /^(?\d{1,2})\:(?\d{1,2})(\:(?\d{1,2}))?$/; + + if (start.date) { + const startDate = new Date(start.date); + if (start.time) { + const time = timeRegex.exec(start.time); + const groups = time?.groups ?? {}; + startDate.setHours(Number(groups.hours)); + startDate.setMinutes(Number(groups.minutes)); + if (groups.seconds) { + startDate.setSeconds(Number(groups.seconds)); + } + } + newSearch.set("timeStart", startDate.toISOString()); + } + if (end.date) { + const endDate = new Date(end.date); + if (end.time) { + const time = timeRegex.exec(end.time); + const groups = time?.groups ?? {}; + endDate.setHours(Number(groups.hours)); + endDate.setMinutes(Number(groups.minutes)); + if (groups.seconds) { + endDate.setSeconds(Number(groups.seconds)); + } + } + newSearch.set("timeEnd", endDate.toISOString()); + } + router.replace(`${path}?${newSearch.toString()}`); + } + + function getDateTime(date: Date) { + return `${date.getHours()}:${date.getMinutes()}`; + } + + const providerOptions = (filterOptions?.providers ?? []).map((p) => ({ + value: String(p.id), + label: p.name ?? `Provider #${p.id}` + })); + const modelOptions = (filterOptions?.models ?? []).map((m) => ({ + value: m, + label: m + })); + const resourceOptions = (filterOptions?.resources ?? []).map((r) => ({ + value: String(r.id), + label: r.name ?? `Resource #${r.id}` + })); + const roleOptions = (filterOptions?.roles ?? []).map((r) => ({ + value: String(r.id), + label: r.name ?? `Role #${r.id}` + })); + const userOptions = (filterOptions?.users ?? []).map((u) => ({ + value: u.id, + label: u.email ?? u.id + })); + + const tabs: TabItem[] = [ + { title: "Overview", href: "#" }, + { title: "Provider usage", href: "#" }, + { title: "Resources", href: "#" }, + { title: "Users & roles", href: "#" } + ]; + + return ( +
+ + +
+ + + + +
+ setFilter("providerId", v)} + /> + setFilter("model", v)} + /> + setFilter("resourceId", v)} + /> + setFilter("roleId", v)} + /> + setFilter("userId", v)} + /> + + {!isEmptySearchParams && ( + + )} +
+
+
+ +
+
+
+ + + + + + + +
+ ); +} + +type FilterSelectProps = { + id: string; + label: string; + value?: string; + options: { value: string; label: string }[]; + placeholder: string; + onValueChange: (value?: string) => void; +}; + +function FilterSelect(props: FilterSelectProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/ai-usage-analytics/OverviewTab.tsx b/src/components/ai-usage-analytics/OverviewTab.tsx new file mode 100644 index 000000000..30d4478b1 --- /dev/null +++ b/src/components/ai-usage-analytics/OverviewTab.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { + SERIES_COLORS, + buildSeriesFromData, + compactNumberFormatter, + formatCost +} from "./shared"; + +type OverviewTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +const TOKEN_TYPE_LABELS: Record = { + promptTokens: "Prompt", + cacheReadTokens: "Cache read", + cacheWriteTokens: "Cache write", + completionTokens: "Completion", + reasoningTokens: "Reasoning" +}; + +export function OverviewTab(props: OverviewTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.overview({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const requestsSeries = [ + { key: "requests", label: "Requests", color: SERIES_COLORS[0] } + ]; + const tokensSeries = Object.keys(TOKEN_TYPE_LABELS).map((key, i) => ({ + key, + label: TOKEN_TYPE_LABELS[key], + color: SERIES_COLORS[i % SERIES_COLORS.length] + })); + const costSeries = [ + { key: "cost", label: "Cost", color: SERIES_COLORS[0] } + ]; + + const modelCostSeries = buildSeriesFromData( + data?.modelCostPerDay ?? [], + (key) => key + ); + const modelTokensSeries = buildSeriesFromData( + data?.modelTokensPerDay ?? [], + (key) => key + ); + + const topModels: TopEntity[] = (data?.topModels ?? []).map((m) => ({ + key: m.model, + label: m.model, + requests: m.requests, + totalTokens: m.totalTokens, + costUsd: m.costUsd + })); + + return ( +
+ + + + + Total requests + + {data + ? compactNumberFormatter.format( + data.totalRequests + ) + : "--"} + + + + Total tokens + + {data + ? compactNumberFormatter.format( + data.totalTokens + ) + : "--"} + + + + Total cost + + {data ? formatCost(data.totalCost) : "--"} + + + + Estimated + + {data + ? `${Math.round(data.estimatedPercent)}%` + : "--"} + + + + + + +
+ + +

Request volume

+
+ + + +
+ + +

Token usage

+
+ + + +
+ + +

Cost

+
+ + formatCost(v)} + /> + +
+
+ +
+ + +

Model cost

+
+ + formatCost(v)} + /> + +
+ + +

Model tokens

+
+ + + +
+
+ + + +

Top models

+
+ + + +
+
+ ); +} diff --git a/src/components/ai-usage-analytics/ProvidersTab.tsx b/src/components/ai-usage-analytics/ProvidersTab.tsx new file mode 100644 index 000000000..2187ec53f --- /dev/null +++ b/src/components/ai-usage-analytics/ProvidersTab.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type ProvidersTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +export function ProvidersTab(props: ProvidersTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.providers({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const nameByKey = new Map(); + for (const p of data?.topProviders ?? []) { + nameByKey.set(String(p.providerId), p.name ?? `Provider #${p.providerId}`); + } + const labelFor = (key: string) => nameByKey.get(key) ?? `Provider #${key}`; + + const costSeries = buildSeriesFromData( + data?.providerCostPerDay ?? [], + labelFor + ); + const tokensSeries = buildSeriesFromData( + data?.providerTokensPerDay ?? [], + labelFor + ); + + const topProviders: TopEntity[] = (data?.topProviders ?? []).map((p) => ({ + key: String(p.providerId), + label: p.name ?? `Provider #${p.providerId}`, + requests: p.requests, + totalTokens: p.totalTokens, + costUsd: p.costUsd + })); + + return ( +
+ + +

Top providers

+
+ + + +
+ +
+ + +

Provider cost

+
+ + formatCost(v)} + /> + +
+ + +

Provider token usage

+
+ + + +
+
+
+ ); +} diff --git a/src/components/ai-usage-analytics/ResourcesTab.tsx b/src/components/ai-usage-analytics/ResourcesTab.tsx new file mode 100644 index 000000000..39cba68d7 --- /dev/null +++ b/src/components/ai-usage-analytics/ResourcesTab.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type ResourcesTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +function resourceTypeLabel(type: "public" | "site" | null) { + if (type === "public") return "Resource"; + if (type === "site") return "Site resource"; + return undefined; +} + +export function ResourcesTab(props: ResourcesTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.resources({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const nameByKey = new Map(); + for (const r of data?.topResources ?? []) { + nameByKey.set(r.key, r.name ?? r.key); + } + const labelFor = (key: string) => + key === "none" ? "No resource" : (nameByKey.get(key) ?? key); + + const costSeries = buildSeriesFromData( + data?.resourceCostPerDay ?? [], + labelFor + ); + const tokensSeries = buildSeriesFromData( + data?.resourceTokensPerDay ?? [], + labelFor + ); + + const topResources: TopEntity[] = (data?.topResources ?? []).map((r) => ({ + key: r.key, + label: r.name ?? (r.key === "none" ? "No resource" : r.key), + sublabel: resourceTypeLabel(r.type), + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + return ( +
+ + +

Top resources

+
+ + + +
+ +
+ + +

Resource cost

+
+ + formatCost(v)} + /> + +
+ + +

Resource token usage

+
+ + + +
+
+
+ ); +} diff --git a/src/components/ai-usage-analytics/ToggleableTrendChart.tsx b/src/components/ai-usage-analytics/ToggleableTrendChart.tsx new file mode 100644 index 000000000..5b6c78c5e --- /dev/null +++ b/src/components/ai-usage-analytics/ToggleableTrendChart.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@app/lib/cn"; +import { BarChart3, LineChart as LineChartIcon, LoaderIcon } from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + XAxis, + YAxis +} from "recharts"; +import { Button } from "@app/components/ui/button"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig +} from "@app/components/ui/chart"; + +export type TrendSeries = { + key: string; + label: string; + color: string; +}; + +export interface TrendChartRow { + day: string; + [seriesKey: string]: number | string; +} + +type ToggleableTrendChartProps = { + data: TrendChartRow[]; + series: TrendSeries[]; + isLoading?: boolean; + valueFormatter?: (value: number) => string; + className?: string; +}; + +const compactFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" +}); + +export function ToggleableTrendChart(props: ToggleableTrendChartProps) { + const [chartType, setChartType] = useState<"bar" | "line">("bar"); + + const valueFormatter = props.valueFormatter ?? compactFormatter.format; + + const chartConfig = props.series.reduce((acc, s) => { + acc[s.key] = { label: s.label, color: s.color }; + return acc; + }, {} as ChartConfig); + + const hasData = props.data.length > 0; + + return ( +
+
+ + +
+ + {!hasData ? ( +
+ {props.isLoading ? ( + <> + + Loading... + + ) : ( + "No data" + )} +
+ ) : ( + + {chartType === "bar" ? ( + + } /> + + formatDay(payload?.[0]?.payload?.day) + } + /> + } + /> + + + + {props.series.map((s) => ( + + ))} + + ) : ( + + } /> + + formatDay(payload?.[0]?.payload?.day) + } + /> + } + /> + + + + {props.series.map((s) => ( + + ))} + + )} + + )} +
+ ); +} + +function formatDay(value: unknown) { + if (typeof value !== "string") return ""; + const date = new Date(value); + if (isNaN(date.getTime())) return value; + return date.toLocaleDateString(undefined, { dateStyle: "medium" }); +} diff --git a/src/components/ai-usage-analytics/TopEntitiesList.tsx b/src/components/ai-usage-analytics/TopEntitiesList.tsx new file mode 100644 index 000000000..9f712363f --- /dev/null +++ b/src/components/ai-usage-analytics/TopEntitiesList.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { LoaderIcon } from "lucide-react"; +import { compactNumberFormatter, formatCost } from "./shared"; + +export type TopEntity = { + key: string; + label: string; + sublabel?: string | null; + requests: number; + totalTokens: number; + costUsd: number | null; +}; + +type TopEntitiesListProps = { + entities: TopEntity[]; + isLoading: boolean; + nameColumnLabel: string; + emptyLabel?: string; +}; + +export function TopEntitiesList(props: TopEntitiesListProps) { + const totalCost = props.entities.reduce( + (sum, e) => sum + (e.costUsd ?? 0), + 0 + ); + + return ( +
+ {props.entities.length > 0 && ( +
+
{props.nameColumnLabel}
+
Requests
+
Tokens
+
Cost
+
%
+
+ )} +
    + {props.entities.length === 0 && ( +
    + {props.isLoading ? ( + <> + + Loading... + + ) : ( + (props.emptyLabel ?? "No data") + )} +
    + )} + {props.entities.map((entity) => { + const percent = + totalCost > 0 ? (entity.costUsd ?? 0) / totalCost : 0; + return ( +
  1. +
    +
    + {entity.label} + {entity.sublabel && ( + + {entity.sublabel} + + )} +
    +
    + {compactNumberFormatter.format(entity.requests)} +
    +
    + {compactNumberFormatter.format( + entity.totalTokens + )} +
    +
    + {formatCost(entity.costUsd)} +
    +
    + {Math.round(percent * 100)}% +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/ai-usage-analytics/UsersRolesTab.tsx b/src/components/ai-usage-analytics/UsersRolesTab.tsx new file mode 100644 index 000000000..96a814fd6 --- /dev/null +++ b/src/components/ai-usage-analytics/UsersRolesTab.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { aiUsageAnalyticsQueries } from "@app/lib/queries"; +import type { AiUsageAnalyticsFilters } from "@app/lib/queries"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ToggleableTrendChart } from "./ToggleableTrendChart"; +import { TopEntitiesList, type TopEntity } from "./TopEntitiesList"; +import { buildSeriesFromData, formatCost } from "./shared"; + +type UsersRolesTabProps = { + orgId: string; + filters: AiUsageAnalyticsFilters; +}; + +const UNKNOWN_USER_KEY = "unknown"; + +export function UsersRolesTab(props: UsersRolesTabProps) { + const { data, isLoading } = useQuery( + aiUsageAnalyticsQueries.usersRoles({ + orgId: props.orgId, + filters: props.filters + }) + ); + + const roleNameByKey = new Map(); + for (const r of data?.topRoles ?? []) { + roleNameByKey.set(String(r.roleId), r.name ?? `Role #${r.roleId}`); + } + const roleLabelFor = (key: string) => + roleNameByKey.get(key) ?? `Role #${key}`; + + const userEmailByKey = new Map(); + for (const u of data?.topUsers ?? []) { + if (u.userId) { + userEmailByKey.set(u.userId, u.email ?? u.userId); + } + } + const userLabelFor = (key: string) => + key === UNKNOWN_USER_KEY + ? "Unknown user" + : (userEmailByKey.get(key) ?? key); + + const roleCostSeries = buildSeriesFromData( + data?.roleCostPerDay ?? [], + roleLabelFor + ); + const roleTokensSeries = buildSeriesFromData( + data?.roleTokensPerDay ?? [], + roleLabelFor + ); + const userCostSeries = buildSeriesFromData( + data?.userCostPerDay ?? [], + userLabelFor + ); + const userTokensSeries = buildSeriesFromData( + data?.userTokensPerDay ?? [], + userLabelFor + ); + + const topRoles: TopEntity[] = (data?.topRoles ?? []).map((r) => ({ + key: String(r.roleId), + label: r.name ?? `Role #${r.roleId}`, + requests: r.requests, + totalTokens: r.totalTokens, + costUsd: r.costUsd + })); + + const topUsers: TopEntity[] = (data?.topUsers ?? []).map((u) => ({ + key: u.userId ?? UNKNOWN_USER_KEY, + label: u.email ?? u.userId ?? "Unknown user", + requests: u.requests, + totalTokens: u.totalTokens, + costUsd: u.costUsd + })); + + return ( +
+
+

Roles

+ + +

Top roles

+
+ + + +
+
+ + +

Role cost

+
+ + formatCost(v)} + /> + +
+ + +

Role token usage

+
+ + + +
+
+
+ +
+

Users

+ + +

Top users

+
+ + + +
+
+ + +

User cost

+
+ + formatCost(v)} + /> + +
+ + +

User token usage

+
+ + + +
+
+
+
+ ); +} diff --git a/src/components/ai-usage-analytics/shared.ts b/src/components/ai-usage-analytics/shared.ts new file mode 100644 index 000000000..1f551f8fa --- /dev/null +++ b/src/components/ai-usage-analytics/shared.ts @@ -0,0 +1,64 @@ +import type { TrendSeries } from "./ToggleableTrendChart"; + +// Matches the theme's 5 categorical chart colors (--chart-1..--chart-5 in +// src/app/globals.css) - the same ceiling RequestChart already respects. +export const SERIES_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)" +]; +export const OTHER_COLOR = "var(--muted-foreground)"; +export const OTHER_KEY = "other"; + +// The server already collapsed each day's row down to the top-N dimension +// keys (already ranked) plus an optional "other" bucket - so the full set of +// series can be derived straight from the data's own keys, no separate +// top-list needed. Assigns one categorical color per key, "other" last. +export function buildSeriesFromData( + data: Array>, + labelFor: (key: string) => string +): TrendSeries[] { + const keys = new Set(); + for (const row of data) { + for (const key of Object.keys(row)) { + if (key !== "day" && key !== OTHER_KEY) { + keys.add(key); + } + } + } + + const series: TrendSeries[] = [...keys].map((key, i) => ({ + key, + label: labelFor(key), + color: SERIES_COLORS[i % SERIES_COLORS.length] + })); + + const hasOther = data.some((row) => OTHER_KEY in row); + if (hasOther) { + series.push({ key: OTHER_KEY, label: "Other", color: OTHER_COLOR }); + } + + return series; +} + +export const currencyFormatter = new Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 2 +}); + +export const compactNumberFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1, + notation: "compact", + compactDisplay: "short" +}); + +export const exactNumberFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 0 +}); + +export function formatCost(value: number | null | undefined) { + return currencyFormatter.format(value ?? 0); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 0275d108b..bf7f69b8f 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -6,7 +6,14 @@ import { type BatchedStatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; -import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; +import type { + QueryRequestAnalyticsResponse, + QueryAiUsageFilterOptionsResponse, + QueryAiUsageOverviewResponse, + QueryAiUsageProvidersResponse, + QueryAiUsageResourcesResponse, + QueryAiUsageUsersRolesResponse +} from "@server/routers/auditLogs"; import type { QueryAccessAuditLogResponse, QueryActionAuditLogResponse, @@ -928,6 +935,32 @@ export const logAnalyticsFiltersSchema = z.object({ export type LogAnalyticsFilters = z.output; +export const aiUsageAnalyticsFiltersSchema = z.object({ + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .optional() + .catch(undefined), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .optional() + .catch(undefined), + providerId: z.coerce.number().optional().catch(undefined), + model: z.string().optional().catch(undefined), + resourceId: z.coerce.number().optional().catch(undefined), + roleId: z.coerce.number().optional().catch(undefined), + userId: z.string().optional().catch(undefined) +}); + +export type AiUsageAnalyticsFilters = z.output< + typeof aiUsageAnalyticsFiltersSchema +>; + export const httpLogsFiltersSchema = z.object({ timeStart: z .string() @@ -1242,6 +1275,137 @@ export const logQueries = { }) }; +export const aiUsageAnalyticsQueries = { + filterOptions: ({ + orgId, + filters + }: { + orgId: string; + filters: Pick; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/filters`, { + params: filters, + signal + }); + return res.data.data; + } + }), + + overview: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/overview`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }), + + providers: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/providers`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }), + + resources: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/resources`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }), + + usersRoles: ({ + orgId, + filters + }: { + orgId: string; + filters: AiUsageAnalyticsFilters; + }) => + queryOptions({ + queryKey: [ + "AI_USAGE_ANALYTICS", + orgId, + "USERS_ROLES", + filters + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/logs/ai/usage/users-roles`, { + params: filters, + signal + }); + return res.data.data; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; + } + }) +}; + export const aiProviderQueries = { providerTargets: ({ providerId }: { providerId: number }) => queryOptions({