mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-13 07:50:22 +02:00
basic ai analytics created
This commit is contained in:
@@ -3397,6 +3397,8 @@
|
|||||||
"aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization",
|
"aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization",
|
||||||
"sidebarLogsAi": "AI Session Logs",
|
"sidebarLogsAi": "AI Session Logs",
|
||||||
"commandLogsAi": "AI Session Logs",
|
"commandLogsAi": "AI Session Logs",
|
||||||
|
"sidebarLogsAiUsage": "AI Usage Analytics",
|
||||||
|
"commandLogsAiUsage": "AI Usage Analytics",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"capability": "Capability",
|
"capability": "Capability",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
|
|||||||
@@ -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<typeof aiUsageAnalyticsCombined>;
|
||||||
|
|
||||||
|
// 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<string[] | undefined> {
|
||||||
|
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<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))`
|
||||||
|
: sql<string>`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DailyMetricRow<K extends string> = {
|
||||||
|
day: string;
|
||||||
|
key: K;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ranks dimension keys by total value and returns the top N.
|
||||||
|
export function pickTopNKeys<K extends string>(
|
||||||
|
totals: Map<K, number>,
|
||||||
|
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<K extends string>(
|
||||||
|
rows: DailyMetricRow<K>[],
|
||||||
|
topKeys: K[]
|
||||||
|
): DayValueRow[] {
|
||||||
|
const topSet = new Set<string>(topKeys);
|
||||||
|
const byDay = new Map<string, Record<string, number>>();
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
@@ -3,3 +3,8 @@ export * from "./queryRequestAnalytics";
|
|||||||
export * from "./exportRequestAuditLog";
|
export * from "./exportRequestAuditLog";
|
||||||
export * from "./queryAiSessionLog";
|
export * from "./queryAiSessionLog";
|
||||||
export * from "./exportAiSessionLog";
|
export * from "./exportAiSessionLog";
|
||||||
|
export * from "./queryAiUsageFilterOptions";
|
||||||
|
export * from "./queryAiUsageOverview";
|
||||||
|
export * from "./queryAiUsageProviders";
|
||||||
|
export * from "./queryAiUsageResources";
|
||||||
|
export * from "./queryAiUsageUsersRoles";
|
||||||
|
|||||||
@@ -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<typeof queryAiUsageFilterOptionsCombined>;
|
||||||
|
|
||||||
|
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||||
|
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<number, string | null>();
|
||||||
|
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<ReturnType<typeof query>>;
|
||||||
|
|
||||||
|
export async function queryAiUsageFilterOptions(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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<QueryAiUsageFilterOptionsResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
|
||||||
|
cacheReadTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`,
|
||||||
|
cacheWriteTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`,
|
||||||
|
completionTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`,
|
||||||
|
reasoningTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`,
|
||||||
|
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||||
|
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||||
|
estimatedRequests: sql<number>`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<number>`COALESCE(SUM(${aiUsageRecords.promptTokens}), 0)`,
|
||||||
|
cacheReadTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheReadTokens}), 0)`,
|
||||||
|
cacheWriteTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.cacheWriteTokens}), 0)`,
|
||||||
|
completionTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.completionTokens}), 0)`,
|
||||||
|
reasoningTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.reasoningTokens}), 0)`
|
||||||
|
})
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(baseConditions)
|
||||||
|
.groupBy(dayExpr)
|
||||||
|
.orderBy(dayExpr);
|
||||||
|
|
||||||
|
const costPerDay = await db
|
||||||
|
.select({
|
||||||
|
day: dayExpr.as("day"),
|
||||||
|
cost: sql<number>`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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||||
|
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
|
||||||
|
})
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(baseConditions)
|
||||||
|
.groupBy(dayExpr, aiUsageRecords.requestedModel)
|
||||||
|
.orderBy(dayExpr);
|
||||||
|
|
||||||
|
const modelCostTotals = new Map<string, number>();
|
||||||
|
const modelTokenTotals = new Map<string, number>();
|
||||||
|
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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||||
|
costUsd: sql<number>`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<ReturnType<typeof query>>;
|
||||||
|
|
||||||
|
export async function queryAiUsageOverview(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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<QueryAiUsageOverviewResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||||
|
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
|
||||||
|
})
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(baseConditions)
|
||||||
|
.groupBy(dayExpr, aiUsageRecords.providerId)
|
||||||
|
.orderBy(dayExpr);
|
||||||
|
|
||||||
|
const costTotals = new Map<string, number>();
|
||||||
|
const tokenTotals = new Map<string, number>();
|
||||||
|
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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||||
|
costUsd: sql<number>`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<number, string | null>();
|
||||||
|
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<ReturnType<typeof query>>;
|
||||||
|
|
||||||
|
export async function queryAiUsageProviders(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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<QueryAiUsageProvidersResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||||
|
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
|
||||||
|
})
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(baseConditions)
|
||||||
|
.groupBy(dayExpr, aiUsageRecords.resourceId, aiUsageRecords.siteResourceId)
|
||||||
|
.orderBy(dayExpr);
|
||||||
|
|
||||||
|
const costTotals = new Map<string, number>();
|
||||||
|
const tokenTotals = new Map<string, number>();
|
||||||
|
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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||||
|
costUsd: sql<number>`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<string, string | null>();
|
||||||
|
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<ReturnType<typeof query>>;
|
||||||
|
|
||||||
|
export async function queryAiUsageResources(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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<QueryAiUsageResourcesResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||||
|
tokens: sql<number>`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<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||||
|
costUsd: sql<number>`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<string, string | null>();
|
||||||
|
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<string, number>();
|
||||||
|
const userTokenTotals = new Map<string, number>();
|
||||||
|
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<string, { roleId: number; name: string | null }[]>();
|
||||||
|
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<string, number>();
|
||||||
|
const roleTokenTotals = new Map<string, number>();
|
||||||
|
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<ReturnType<typeof query>>;
|
||||||
|
|
||||||
|
export async function queryAiUsageUsersRoles(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
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<QueryAiUsageUsersRolesResponse>(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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1502,6 +1502,41 @@ authenticated.get(
|
|||||||
logs.exportAiSessionLogs
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId/blueprints",
|
"/org/:orgId/blueprints",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -1545,6 +1545,41 @@ authenticated.get(
|
|||||||
logs.exportAiSessionLogs
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/analytics",
|
"/org/:orgId/logs/analytics",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="AI Usage Analytics"
|
||||||
|
description="Analyze AI gateway cost, token usage, and activity across providers, resources, roles, and users"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<AiUsageAnalyticsData orgId={orgId} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Cable,
|
Cable,
|
||||||
ChartLine,
|
ChartLine,
|
||||||
|
Coins,
|
||||||
Combine,
|
Combine,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
@@ -233,6 +234,11 @@ export const orgNavSections = (
|
|||||||
href: "/{orgId}/settings/logs/ai",
|
href: "/{orgId}/settings/logs/ai",
|
||||||
icon: <Bot className="size-4 flex-none" />
|
icon: <Bot className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "sidebarLogsAiUsage",
|
||||||
|
href: "/{orgId}/settings/logs/ai-usage",
|
||||||
|
icon: <Coins className="size-4 flex-none" />
|
||||||
|
},
|
||||||
...(!env?.flags.disableEnterpriseFeatures
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -532,6 +538,11 @@ export const commandBarNavSections = (
|
|||||||
href: "/{orgId}/settings/logs/ai",
|
href: "/{orgId}/settings/logs/ai",
|
||||||
icon: <Bot className="size-4 flex-none" />
|
icon: <Bot className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "commandLogsAiUsage",
|
||||||
|
href: "/{orgId}/settings/logs/ai-usage",
|
||||||
|
icon: <Coins className="size-4 flex-none" />
|
||||||
|
},
|
||||||
...(!env?.flags.disableEnterpriseFeatures
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col lg:flex-row items-start lg:items-end w-full gap-2">
|
||||||
|
<DateRangePicker
|
||||||
|
startValue={{
|
||||||
|
date: dateRange.startDate,
|
||||||
|
time: dateRange.startDate
|
||||||
|
? getDateTime(dateRange.startDate)
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
endValue={{
|
||||||
|
date: dateRange.endDate,
|
||||||
|
time: dateRange.endDate
|
||||||
|
? getDateTime(dateRange.endDate)
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
onRangeChange={handleTimeRangeUpdate}
|
||||||
|
className="flex-wrap gap-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<FilterSelect
|
||||||
|
id="providerId"
|
||||||
|
label="Provider"
|
||||||
|
value={filters.providerId?.toString()}
|
||||||
|
options={providerOptions}
|
||||||
|
placeholder="All providers"
|
||||||
|
onValueChange={(v) => setFilter("providerId", v)}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
id="model"
|
||||||
|
label="Model"
|
||||||
|
value={filters.model}
|
||||||
|
options={modelOptions}
|
||||||
|
placeholder="All models"
|
||||||
|
onValueChange={(v) => setFilter("model", v)}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
id="resourceId"
|
||||||
|
label="Resource"
|
||||||
|
value={filters.resourceId?.toString()}
|
||||||
|
options={resourceOptions}
|
||||||
|
placeholder="All resources"
|
||||||
|
onValueChange={(v) => setFilter("resourceId", v)}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
id="roleId"
|
||||||
|
label="Role"
|
||||||
|
value={filters.roleId?.toString()}
|
||||||
|
options={roleOptions}
|
||||||
|
placeholder="All roles"
|
||||||
|
onValueChange={(v) => setFilter("roleId", v)}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
id="userId"
|
||||||
|
label="User"
|
||||||
|
value={filters.userId}
|
||||||
|
options={userOptions}
|
||||||
|
placeholder="All users"
|
||||||
|
onValueChange={(v) => setFilter("userId", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isEmptySearchParams && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.replace(path)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
Reset filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [
|
||||||
|
...AI_USAGE_ANALYTICS_QUERY_PREFIX,
|
||||||
|
props.orgId
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
isFetching && "animate-spin"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<HorizontalTabs items={tabs} clientSide>
|
||||||
|
<OverviewTab orgId={props.orgId} filters={filters} />
|
||||||
|
<ProvidersTab orgId={props.orgId} filters={filters} />
|
||||||
|
<ResourcesTab orgId={props.orgId} filters={filters} />
|
||||||
|
<UsersRolesTab orgId={props.orgId} filters={filters} />
|
||||||
|
</HorizontalTabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterSelectProps = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
placeholder: string;
|
||||||
|
onValueChange: (value?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilterSelect(props: FilterSelectProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-2 w-44">
|
||||||
|
<Label htmlFor={props.id}>{props.label}</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(newValue) =>
|
||||||
|
props.onValueChange(
|
||||||
|
newValue === "all" ? undefined : newValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={props.value ?? "all"}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={props.id} className="w-full">
|
||||||
|
<SelectValue placeholder={props.placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="w-full">
|
||||||
|
<SelectItem value="all">{props.placeholder}</SelectItem>
|
||||||
|
{props.options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<InfoSections cols={4}>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Total requests</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{data
|
||||||
|
? compactNumberFormatter.format(
|
||||||
|
data.totalRequests
|
||||||
|
)
|
||||||
|
: "--"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Total tokens</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{data
|
||||||
|
? compactNumberFormatter.format(
|
||||||
|
data.totalTokens
|
||||||
|
)
|
||||||
|
: "--"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Total cost</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{data ? formatCost(data.totalCost) : "--"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Estimated</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{data
|
||||||
|
? `${Math.round(data.estimatedPercent)}%`
|
||||||
|
: "--"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Request volume</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.requestsPerDay ?? []}
|
||||||
|
series={requestsSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Token usage</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.tokensPerDay ?? []}
|
||||||
|
series={tokensSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Cost</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.costPerDay ?? []}
|
||||||
|
series={costSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
valueFormatter={(v) => formatCost(v)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Model cost</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.modelCostPerDay ?? []}
|
||||||
|
series={modelCostSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
valueFormatter={(v) => formatCost(v)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Model tokens</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.modelTokensPerDay ?? []}
|
||||||
|
series={modelTokensSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Top models</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TopEntitiesList
|
||||||
|
entities={topModels}
|
||||||
|
isLoading={isLoading}
|
||||||
|
nameColumnLabel="Model"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string>();
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Top providers</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TopEntitiesList
|
||||||
|
entities={topProviders}
|
||||||
|
isLoading={isLoading}
|
||||||
|
nameColumnLabel="Provider"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Provider cost</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.providerCostPerDay ?? []}
|
||||||
|
series={costSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
valueFormatter={(v) => formatCost(v)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Provider token usage</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.providerTokensPerDay ?? []}
|
||||||
|
series={tokensSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string>();
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Top resources</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TopEntitiesList
|
||||||
|
entities={topResources}
|
||||||
|
isLoading={isLoading}
|
||||||
|
nameColumnLabel="Resource"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Resource cost</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.resourceCostPerDay ?? []}
|
||||||
|
series={costSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
valueFormatter={(v) => formatCost(v)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Resource token usage</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.resourceTokensPerDay ?? []}
|
||||||
|
series={tokensSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className={cn("relative flex flex-col gap-2", props.className)}>
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={chartType === "bar" ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setChartType("bar")}
|
||||||
|
className="gap-1.5 px-2"
|
||||||
|
>
|
||||||
|
<BarChart3 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={chartType === "line" ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setChartType("line")}
|
||||||
|
className="gap-1.5 px-2"
|
||||||
|
>
|
||||||
|
<LineChartIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasData ? (
|
||||||
|
<div className="flex h-64 w-full items-center justify-center text-muted-foreground gap-2">
|
||||||
|
{props.isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"No data"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="min-h-50 w-full h-64"
|
||||||
|
>
|
||||||
|
{chartType === "bar" ? (
|
||||||
|
<BarChart accessibilityLayer data={props.data}>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator="dot"
|
||||||
|
labelFormatter={(_value, payload) =>
|
||||||
|
formatDay(payload?.[0]?.payload?.day)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={valueFormatter}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatDay}
|
||||||
|
/>
|
||||||
|
{props.series.map((s) => (
|
||||||
|
<Bar
|
||||||
|
key={s.key}
|
||||||
|
dataKey={s.key}
|
||||||
|
stackId="stack"
|
||||||
|
fill={`var(--color-${s.key})`}
|
||||||
|
radius={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
) : (
|
||||||
|
<LineChart accessibilityLayer data={props.data}>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator="line"
|
||||||
|
labelFormatter={(_value, payload) =>
|
||||||
|
formatDay(payload?.[0]?.payload?.day)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={valueFormatter}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="day"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatDay}
|
||||||
|
/>
|
||||||
|
{props.series.map((s) => (
|
||||||
|
<Line
|
||||||
|
key={s.key}
|
||||||
|
dataKey={s.key}
|
||||||
|
stroke={`var(--color-${s.key})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="transparent"
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="h-full flex flex-col gap-2">
|
||||||
|
{props.entities.length > 0 && (
|
||||||
|
<div className="grid grid-cols-12 text-sm text-muted-foreground font-semibold h-4">
|
||||||
|
<div className="col-span-5">{props.nameColumnLabel}</div>
|
||||||
|
<div className="col-span-2 text-end">Requests</div>
|
||||||
|
<div className="col-span-2 text-end">Tokens</div>
|
||||||
|
<div className="col-span-2 text-end">Cost</div>
|
||||||
|
<div className="col-span-1 text-end">%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ol className="w-full overflow-auto gap-1 flex flex-col max-h-100">
|
||||||
|
{props.entities.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center size-full text-muted-foreground gap-2 py-8">
|
||||||
|
{props.isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
(props.emptyLabel ?? "No data")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.entities.map((entity) => {
|
||||||
|
const percent =
|
||||||
|
totalCost > 0 ? (entity.costUsd ?? 0) / totalCost : 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={entity.key}
|
||||||
|
className="w-full grid grid-cols-12 rounded-xs hover:bg-muted relative items-center text-sm py-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute bg-[#f36117]/40 top-0 bottom-0 left-0 rounded-xs"
|
||||||
|
style={{ width: `${percent * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div className="col-span-5 px-2 relative z-1 flex flex-col min-w-0">
|
||||||
|
<span className="truncate">{entity.label}</span>
|
||||||
|
{entity.sublabel && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{entity.sublabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-end relative z-1">
|
||||||
|
{compactNumberFormatter.format(entity.requests)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-end relative z-1">
|
||||||
|
{compactNumberFormatter.format(
|
||||||
|
entity.totalTokens
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-end relative z-1">
|
||||||
|
{formatCost(entity.costUsd)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 text-end relative z-1">
|
||||||
|
{Math.round(percent * 100)}%
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string>();
|
||||||
|
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<string, string>();
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<h3 className="font-semibold text-muted-foreground">Roles</h3>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Top roles</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TopEntitiesList
|
||||||
|
entities={topRoles}
|
||||||
|
isLoading={isLoading}
|
||||||
|
nameColumnLabel="Role"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="grid lg:grid-cols-2 gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Role cost</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.roleCostPerDay ?? []}
|
||||||
|
series={roleCostSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
valueFormatter={(v) => formatCost(v)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Role token usage</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.roleTokensPerDay ?? []}
|
||||||
|
series={roleTokensSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<h3 className="font-semibold text-muted-foreground">Users</h3>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">Top users</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TopEntitiesList
|
||||||
|
entities={topUsers}
|
||||||
|
isLoading={isLoading}
|
||||||
|
nameColumnLabel="User"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="grid lg:grid-cols-2 gap-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">User cost</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.userCostPerDay ?? []}
|
||||||
|
series={userCostSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
valueFormatter={(v) => formatCost(v)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="font-semibold">User token usage</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ToggleableTrendChart
|
||||||
|
data={data?.userTokensPerDay ?? []}
|
||||||
|
series={userTokensSeries}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Record<string, number | string>>,
|
||||||
|
labelFor: (key: string) => string
|
||||||
|
): TrendSeries[] {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
+165
-1
@@ -6,7 +6,14 @@ import {
|
|||||||
type BatchedStatusHistoryResponse
|
type BatchedStatusHistoryResponse
|
||||||
} from "@server/lib/statusHistory";
|
} from "@server/lib/statusHistory";
|
||||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
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 {
|
import type {
|
||||||
QueryAccessAuditLogResponse,
|
QueryAccessAuditLogResponse,
|
||||||
QueryActionAuditLogResponse,
|
QueryActionAuditLogResponse,
|
||||||
@@ -928,6 +935,32 @@ export const logAnalyticsFiltersSchema = z.object({
|
|||||||
|
|
||||||
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
|
export type LogAnalyticsFilters = z.output<typeof logAnalyticsFiltersSchema>;
|
||||||
|
|
||||||
|
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({
|
export const httpLogsFiltersSchema = z.object({
|
||||||
timeStart: z
|
timeStart: z
|
||||||
.string()
|
.string()
|
||||||
@@ -1242,6 +1275,137 @@ export const logQueries = {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const aiUsageAnalyticsQueries = {
|
||||||
|
filterOptions: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<QueryAiUsageFilterOptionsResponse>
|
||||||
|
>(`/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<QueryAiUsageOverviewResponse>
|
||||||
|
>(`/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<QueryAiUsageProvidersResponse>
|
||||||
|
>(`/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<QueryAiUsageResourcesResponse>
|
||||||
|
>(`/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<QueryAiUsageUsersRolesResponse>
|
||||||
|
>(`/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 = {
|
export const aiProviderQueries = {
|
||||||
providerTargets: ({ providerId }: { providerId: number }) =>
|
providerTargets: ({ providerId }: { providerId: number }) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
|
|||||||
Reference in New Issue
Block a user