basic ai analytics created

This commit is contained in:
Owen
2026-08-11 17:52:27 -04:00
parent 0016b8fce7
commit b08e875b37
21 changed files with 2820 additions and 1 deletions
@@ -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 }));
}
+5
View File
@@ -3,3 +3,8 @@ export * from "./queryRequestAnalytics";
export * from "./exportRequestAuditLog";
export * from "./queryAiSessionLog";
export * from "./exportAiSessionLog";
export * from "./queryAiUsageFilterOptions";
export * from "./queryAiUsageOverview";
export * from "./queryAiUsageProviders";
export * from "./queryAiUsageResources";
export * from "./queryAiUsageUsersRoles";
@@ -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")
);
}
}
+35
View File
@@ -1502,6 +1502,41 @@ authenticated.get(
logs.exportAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/usage/filters",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageFilterOptions
);
authenticated.get(
"/org/:orgId/logs/ai/usage/overview",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageOverview
);
authenticated.get(
"/org/:orgId/logs/ai/usage/providers",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageProviders
);
authenticated.get(
"/org/:orgId/logs/ai/usage/resources",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageResources
);
authenticated.get(
"/org/:orgId/logs/ai/usage/users-roles",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageUsersRoles
);
authenticated.get(
"/org/:orgId/blueprints",
verifyOrgAccess,
+35
View File
@@ -1545,6 +1545,41 @@ authenticated.get(
logs.exportAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/usage/filters",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageFilterOptions
);
authenticated.get(
"/org/:orgId/logs/ai/usage/overview",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageOverview
);
authenticated.get(
"/org/:orgId/logs/ai/usage/providers",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageProviders
);
authenticated.get(
"/org/:orgId/logs/ai/usage/resources",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageResources
);
authenticated.get(
"/org/:orgId/logs/ai/usage/users-roles",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageUsersRoles
);
authenticated.get(
"/org/:orgId/logs/analytics",
verifyApiKeyOrgAccess,