mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-23 20:50:18 +02:00
Merge branch 'dev' into feat/ip-filtering
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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(),
|
||||
virtualApiKeyId: 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,
|
||||
data.virtualApiKeyId
|
||||
? eq(aiUsageRecords.virtualApiKeyId, data.virtualApiKeyId)
|
||||
: 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 }));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
queryAiSessionLogsQuery,
|
||||
queryAiSessionLogsParams,
|
||||
queryAiSession,
|
||||
countAiSessionQuery
|
||||
} from "./queryAiSessionLog";
|
||||
import { generateCSV } from "./generateCSV";
|
||||
|
||||
const MAX_EXPORT_LIMIT = 50_000;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/ai/export",
|
||||
description: "Export the AI gateway session log for an organization as CSV",
|
||||
tags: [OpenAPITags.Logs],
|
||||
request: {
|
||||
query: queryAiSessionLogsQuery.omit({
|
||||
limit: true,
|
||||
offset: true
|
||||
}),
|
||||
params: queryAiSessionLogsParams
|
||||
},
|
||||
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 async function exportAiSessionLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const [{ count }] = await countAiSessionQuery(data);
|
||||
if (count > MAX_EXPORT_LIMIT) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryAiSession(data);
|
||||
|
||||
const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
|
||||
|
||||
const csvData = generateCSV(log);
|
||||
|
||||
res.setHeader("Content-Type", "text/csv");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="ai-session-logs-${data.orgId}-${Date.now()}.csv"`
|
||||
);
|
||||
|
||||
return res.send(csvData);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
export * from "./queryRequestAuditLog";
|
||||
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";
|
||||
export * from "./queryAiUsageVirtualApiKeys";
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
logsDb,
|
||||
aiSessionLog,
|
||||
aiProviders,
|
||||
aiUsageRecords,
|
||||
resources,
|
||||
siteResources,
|
||||
users,
|
||||
virtualApiKeys,
|
||||
db,
|
||||
primaryDb
|
||||
} from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count, desc, inArray, isNull, or } 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 { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
|
||||
import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
|
||||
export const queryAiSessionLogsQuery = z.strictObject({
|
||||
// iso string just validate its a parseable date
|
||||
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())
|
||||
.optional()
|
||||
.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(),
|
||||
capability: z.enum(AI_CAPABILITIES).optional(),
|
||||
resourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive())
|
||||
.optional(),
|
||||
actor: z.string().optional(),
|
||||
virtualApiKeyId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
isStream: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||
.optional(),
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
});
|
||||
|
||||
export const queryAiSessionLogsParams = z.object({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export const queryAiSessionLogsCombined = queryAiSessionLogsQuery.merge(
|
||||
queryAiSessionLogsParams
|
||||
);
|
||||
type Q = z.infer<typeof queryAiSessionLogsCombined>;
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(aiSessionLog.createdAt, data.timeStart),
|
||||
lt(aiSessionLog.createdAt, data.timeEnd),
|
||||
eq(aiSessionLog.orgId, data.orgId),
|
||||
data.providerId
|
||||
? eq(aiSessionLog.providerId, data.providerId)
|
||||
: undefined,
|
||||
data.capability
|
||||
? eq(aiSessionLog.capability, data.capability)
|
||||
: undefined,
|
||||
data.resourceId
|
||||
? or(
|
||||
eq(aiSessionLog.resourceId, data.resourceId),
|
||||
eq(aiSessionLog.siteResourceId, data.resourceId)
|
||||
)
|
||||
: undefined,
|
||||
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
|
||||
data.virtualApiKeyId
|
||||
? eq(aiSessionLog.virtualApiKeyId, data.virtualApiKeyId)
|
||||
: undefined,
|
||||
data.model ? eq(aiSessionLog.requestedModel, data.model) : undefined,
|
||||
data.isStream !== undefined
|
||||
? eq(aiSessionLog.isStream, data.isStream)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function queryAiSession(data: Q) {
|
||||
return logsDb
|
||||
.select({
|
||||
id: aiSessionLog.id,
|
||||
sessionId: aiSessionLog.sessionId,
|
||||
orgId: aiSessionLog.orgId,
|
||||
providerId: aiSessionLog.providerId,
|
||||
capability: aiSessionLog.capability,
|
||||
resourceId: aiSessionLog.resourceId,
|
||||
siteResourceId: aiSessionLog.siteResourceId,
|
||||
userId: aiSessionLog.userId,
|
||||
virtualApiKeyId: aiSessionLog.virtualApiKeyId,
|
||||
requestedModel: aiSessionLog.requestedModel,
|
||||
isStream: aiSessionLog.isStream,
|
||||
requestBody: aiSessionLog.requestBody,
|
||||
responseBody: aiSessionLog.responseBody,
|
||||
normalizedRequest: aiSessionLog.normalizedRequest,
|
||||
normalizedResponse: aiSessionLog.normalizedResponse,
|
||||
truncated: aiSessionLog.truncated,
|
||||
statusCode: aiSessionLog.statusCode,
|
||||
createdAt: aiSessionLog.createdAt
|
||||
})
|
||||
.from(aiSessionLog)
|
||||
.where(getWhere(data))
|
||||
.orderBy(desc(aiSessionLog.createdAt));
|
||||
}
|
||||
|
||||
async function enrichWithDetails(
|
||||
logs: Awaited<ReturnType<typeof queryAiSession>>
|
||||
) {
|
||||
const providerIds = [...new Set(logs.map((log) => log.providerId))];
|
||||
|
||||
const resourceIds = logs
|
||||
.map((log) => log.resourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const siteResourceIds = logs
|
||||
.filter((log) => log.resourceId == null && log.siteResourceId != null)
|
||||
.map((log) => log.siteResourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const userIds = [
|
||||
...new Set(
|
||||
logs
|
||||
.map((log) => log.userId)
|
||||
.filter((id): id is string => id !== null && id !== undefined)
|
||||
)
|
||||
];
|
||||
|
||||
const virtualApiKeyIds = [
|
||||
...new Set(
|
||||
logs
|
||||
.map((log) => log.virtualApiKeyId)
|
||||
.filter((id): id is string => id !== null && id !== undefined)
|
||||
)
|
||||
];
|
||||
|
||||
const providerMap = new Map<
|
||||
number,
|
||||
{ name: string | null; type: string | null }
|
||||
>();
|
||||
if (providerIds.length > 0) {
|
||||
const providerDetails = await primaryDb
|
||||
.select({
|
||||
providerId: aiProviders.providerId,
|
||||
name: aiProviders.name,
|
||||
type: aiProviders.type
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(
|
||||
inArray(
|
||||
aiProviders.providerId,
|
||||
providerIds.filter(
|
||||
(id): id is number => id !== null && id !== undefined
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const p of providerDetails) {
|
||||
providerMap.set(p.providerId, { name: p.name, type: p.type });
|
||||
}
|
||||
}
|
||||
|
||||
const resourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
if (resourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
for (const r of resourceDetails) {
|
||||
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
|
||||
}
|
||||
}
|
||||
|
||||
const siteResourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
niceId: siteResources.niceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
for (const r of siteResourceDetails) {
|
||||
siteResourceMap.set(r.siteResourceId, {
|
||||
name: r.name,
|
||||
niceId: r.niceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const userMap = 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) {
|
||||
userMap.set(u.userId, u.email);
|
||||
}
|
||||
}
|
||||
|
||||
const virtualApiKeyMap = new Map<
|
||||
string,
|
||||
{ name: string | null; lastChars: string }
|
||||
>();
|
||||
if (virtualApiKeyIds.length > 0) {
|
||||
const virtualApiKeyDetails = await db
|
||||
.select({
|
||||
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
|
||||
name: virtualApiKeys.name,
|
||||
lastChars: virtualApiKeys.lastChars
|
||||
})
|
||||
.from(virtualApiKeys)
|
||||
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
|
||||
|
||||
for (const k of virtualApiKeyDetails) {
|
||||
virtualApiKeyMap.set(k.virtualApiKeyId, {
|
||||
name: k.name,
|
||||
lastChars: k.lastChars
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const usageMap = new Map<
|
||||
string,
|
||||
{
|
||||
promptTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
completionTokens: number;
|
||||
reasoningTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number | null;
|
||||
estimated: boolean;
|
||||
}
|
||||
>();
|
||||
const sessionIds = logs.map((log) => log.sessionId);
|
||||
if (sessionIds.length > 0) {
|
||||
const usageDetails = await logsDb
|
||||
.select({
|
||||
sessionId: aiUsageRecords.sessionId,
|
||||
promptTokens: aiUsageRecords.promptTokens,
|
||||
cacheReadTokens: aiUsageRecords.cacheReadTokens,
|
||||
cacheWriteTokens: aiUsageRecords.cacheWriteTokens,
|
||||
completionTokens: aiUsageRecords.completionTokens,
|
||||
reasoningTokens: aiUsageRecords.reasoningTokens,
|
||||
totalTokens: aiUsageRecords.totalTokens,
|
||||
costUsd: aiUsageRecords.costUsd,
|
||||
estimated: aiUsageRecords.estimated
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(inArray(aiUsageRecords.sessionId, sessionIds));
|
||||
|
||||
for (const u of usageDetails) {
|
||||
if (!u.sessionId) continue;
|
||||
usageMap.set(u.sessionId, {
|
||||
promptTokens: u.promptTokens,
|
||||
cacheReadTokens: u.cacheReadTokens,
|
||||
cacheWriteTokens: u.cacheWriteTokens,
|
||||
completionTokens: u.completionTokens,
|
||||
reasoningTokens: u.reasoningTokens,
|
||||
totalTokens: u.totalTokens,
|
||||
costUsd: u.costUsd,
|
||||
estimated: u.estimated
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return logs.map((log) => {
|
||||
const provider = log.providerId
|
||||
? providerMap.get(log.providerId)
|
||||
: null;
|
||||
|
||||
let resourceId = log.resourceId;
|
||||
let resourceName: string | null = null;
|
||||
let resourceNiceId: string | null = null;
|
||||
let resourceType: "public" | "site" | null = null;
|
||||
if (log.resourceId != null) {
|
||||
const details = resourceMap.get(log.resourceId);
|
||||
resourceName = details?.name ?? null;
|
||||
resourceNiceId = details?.niceId ?? null;
|
||||
resourceType = "public";
|
||||
} else if (log.siteResourceId != null) {
|
||||
const details = siteResourceMap.get(log.siteResourceId);
|
||||
resourceId = log.siteResourceId;
|
||||
resourceName = details?.name ?? null;
|
||||
resourceNiceId = details?.niceId ?? null;
|
||||
resourceType = "site";
|
||||
}
|
||||
|
||||
return {
|
||||
...log,
|
||||
resourceId,
|
||||
resourceType,
|
||||
providerName: provider?.name ?? null,
|
||||
providerType: provider?.type ?? null,
|
||||
resourceName,
|
||||
resourceNiceId,
|
||||
userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null,
|
||||
virtualApiKeyName: log.virtualApiKeyId
|
||||
? (virtualApiKeyMap.get(log.virtualApiKeyId)?.name ?? null)
|
||||
: null,
|
||||
virtualApiKeyLastChars: log.virtualApiKeyId
|
||||
? (virtualApiKeyMap.get(log.virtualApiKeyId)?.lastChars ?? null)
|
||||
: null,
|
||||
usage: usageMap.get(log.sessionId) ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function countAiSessionQuery(data: Q) {
|
||||
return logsDb
|
||||
.select({ count: count() })
|
||||
.from(aiSessionLog)
|
||||
.where(getWhere(data));
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/ai",
|
||||
description: "Query the AI gateway session log for an organization",
|
||||
tags: [OpenAPITags.Logs],
|
||||
request: {
|
||||
query: queryAiSessionLogsQuery,
|
||||
params: queryAiSessionLogsParams
|
||||
},
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function queryUniqueFilterAttributes(
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
orgId: string
|
||||
) {
|
||||
const baseConditions = and(
|
||||
gt(aiSessionLog.createdAt, timeStart),
|
||||
lt(aiSessionLog.createdAt, timeEnd),
|
||||
eq(aiSessionLog.orgId, orgId)
|
||||
);
|
||||
|
||||
const DISTINCT_LIMIT = 500;
|
||||
|
||||
const [
|
||||
uniqueProviders,
|
||||
uniqueUsers,
|
||||
uniqueResources,
|
||||
uniqueSiteResources,
|
||||
uniqueModels,
|
||||
uniqueVirtualApiKeys
|
||||
] = await Promise.all([
|
||||
logsDb
|
||||
.selectDistinct({ id: aiSessionLog.providerId })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ userId: aiSessionLog.userId })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiSessionLog.resourceId })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiSessionLog.siteResourceId })
|
||||
.from(aiSessionLog)
|
||||
.where(and(baseConditions, isNull(aiSessionLog.resourceId)))
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ model: aiSessionLog.requestedModel })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiSessionLog.virtualApiKeyId })
|
||||
.from(aiSessionLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1)
|
||||
]);
|
||||
|
||||
const models = uniqueModels
|
||||
.map((row) => row.model)
|
||||
.filter((model): model is string => model !== null);
|
||||
|
||||
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 primaryDb
|
||||
.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 userIds = uniqueUsers
|
||||
.map((row) => row.userId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
let userList: Array<{ id: string; email: 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 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 primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...resourceDetails.map((r) => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...siteResourceDetails.map((r) => ({
|
||||
id: r.siteResourceId,
|
||||
name: r.name
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
const virtualApiKeyIds = uniqueVirtualApiKeys
|
||||
.map((row) => row.id)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
let virtualApiKeyList: Array<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
lastChars: string | null;
|
||||
}> = [];
|
||||
if (virtualApiKeyIds.length > 0) {
|
||||
const virtualApiKeyDetails = await primaryDb
|
||||
.select({
|
||||
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
|
||||
name: virtualApiKeys.name,
|
||||
lastChars: virtualApiKeys.lastChars
|
||||
})
|
||||
.from(virtualApiKeys)
|
||||
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
|
||||
|
||||
virtualApiKeyList = virtualApiKeyDetails.map((k) => ({
|
||||
id: k.virtualApiKeyId,
|
||||
name: k.name,
|
||||
lastChars: k.lastChars
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
providers: sortNamedFilterOptions(providers),
|
||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||
users: userList,
|
||||
virtualApiKeys: virtualApiKeyList,
|
||||
models: models.sort()
|
||||
};
|
||||
}
|
||||
|
||||
export async function queryAiSessionLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAiSessionLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryAiSessionLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryAiSession(data);
|
||||
|
||||
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const log = await enrichWithDetails(logsRaw);
|
||||
|
||||
const totalCountResult = await countAiSessionQuery(data);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
const filterAttributes = await queryUniqueFilterAttributes(
|
||||
data.timeStart,
|
||||
data.timeEnd,
|
||||
data.orgId
|
||||
);
|
||||
|
||||
return response<QueryAiSessionLogResponse>(res, {
|
||||
data: {
|
||||
log,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit: data.limit,
|
||||
offset: data.offset
|
||||
},
|
||||
filterAttributes
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "AI session logs retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
db,
|
||||
logsDb,
|
||||
aiUsageRecords,
|
||||
aiProviders,
|
||||
resources,
|
||||
siteResources,
|
||||
users,
|
||||
roles,
|
||||
userOrgRoles,
|
||||
virtualApiKeys
|
||||
} 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,
|
||||
uniqueVirtualApiKeys
|
||||
] = await Promise.all([
|
||||
logsDb
|
||||
.selectDistinct({ id: aiUsageRecords.providerId })
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ model: aiUsageRecords.requestedModel })
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiUsageRecords.resourceId })
|
||||
.from(aiUsageRecords)
|
||||
.where(and(baseConditions, not(isNull(aiUsageRecords.resourceId))))
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiUsageRecords.siteResourceId })
|
||||
.from(aiUsageRecords)
|
||||
.where(
|
||||
and(
|
||||
baseConditions,
|
||||
isNull(aiUsageRecords.resourceId),
|
||||
not(isNull(aiUsageRecords.siteResourceId))
|
||||
)
|
||||
)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ userId: aiUsageRecords.userId })
|
||||
.from(aiUsageRecords)
|
||||
.where(and(baseConditions, not(isNull(aiUsageRecords.userId))))
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
logsDb
|
||||
.selectDistinct({ id: aiUsageRecords.virtualApiKeyId })
|
||||
.from(aiUsageRecords)
|
||||
.where(
|
||||
and(baseConditions, not(isNull(aiUsageRecords.virtualApiKeyId)))
|
||||
)
|
||||
.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 }));
|
||||
}
|
||||
|
||||
const virtualApiKeyIds = uniqueVirtualApiKeys
|
||||
.map((row) => row.id)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
let virtualApiKeyList: Array<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
lastChars: string;
|
||||
}> = [];
|
||||
if (virtualApiKeyIds.length > 0) {
|
||||
const virtualApiKeyDetails = await db
|
||||
.select({
|
||||
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
|
||||
name: virtualApiKeys.name,
|
||||
lastChars: virtualApiKeys.lastChars
|
||||
})
|
||||
.from(virtualApiKeys)
|
||||
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
|
||||
virtualApiKeyList = virtualApiKeyDetails.map((k) => ({
|
||||
id: k.virtualApiKeyId,
|
||||
name: k.name,
|
||||
lastChars: k.lastChars
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
providers: sortNamedFilterOptions(providers),
|
||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||
roles: sortNamedFilterOptions(roleList),
|
||||
users: userList,
|
||||
virtualApiKeys: virtualApiKeyList,
|
||||
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 { logsDb, 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 logsDb
|
||||
.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 logsDb
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
requests: count()
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(dayExpr)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const tokensPerDay = await logsDb
|
||||
.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 logsDb
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(dayExpr)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const modelByDay = await logsDb
|
||||
.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 logsDb
|
||||
.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,198 @@
|
||||
import { db, logsDb, 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 logsDb
|
||||
.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 logsDb
|
||||
.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.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
for (const p of providerDetails) {
|
||||
nameMap.set(p.providerId, p.name);
|
||||
}
|
||||
}
|
||||
|
||||
const topProviders = topProvidersRaw.map((r) => ({
|
||||
providerId: r.providerId,
|
||||
name: r.providerId ? (nameMap.get(r.providerId) ?? null) : 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,224 @@
|
||||
import { db, logsDb, 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. Uses a dash
|
||||
// rather than a colon so the key stays safe to use as a CSS custom-property
|
||||
// name suffix (e.g. --color-r-1) on the client.
|
||||
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 logsDb
|
||||
.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 logsDb
|
||||
.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,278 @@
|
||||
import {
|
||||
db,
|
||||
logsDb,
|
||||
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 logsDb
|
||||
.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 logsDb
|
||||
.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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { db, logsDb, aiUsageRecords, virtualApiKeys } 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;
|
||||
|
||||
const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown";
|
||||
|
||||
async function query(data: Q) {
|
||||
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
|
||||
const baseConditions = buildAiUsageWhere(data, roleUserIds);
|
||||
const dayExpr = dayBucketExpr();
|
||||
|
||||
const virtualApiKeyByDay = await logsDb
|
||||
.select({
|
||||
day: dayExpr.as("day"),
|
||||
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
|
||||
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
|
||||
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(dayExpr, aiUsageRecords.virtualApiKeyId)
|
||||
.orderBy(dayExpr);
|
||||
|
||||
const virtualApiKeyTotalsRaw = await logsDb
|
||||
.select({
|
||||
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
|
||||
requests: count(),
|
||||
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
|
||||
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
|
||||
})
|
||||
.from(aiUsageRecords)
|
||||
.where(baseConditions)
|
||||
.groupBy(aiUsageRecords.virtualApiKeyId)
|
||||
.orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`))
|
||||
.limit(DISTINCT_LIMIT + 1);
|
||||
|
||||
if (virtualApiKeyTotalsRaw.length > DISTINCT_LIMIT) {
|
||||
throw createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Too many distinct virtual API keys. Please narrow your query."
|
||||
);
|
||||
}
|
||||
|
||||
const virtualApiKeyIds = virtualApiKeyTotalsRaw
|
||||
.map((r) => r.virtualApiKeyId)
|
||||
.filter((id): id is string => id !== null);
|
||||
|
||||
const detailsMap = new Map<
|
||||
string,
|
||||
{ name: string | null; lastChars: string; kind: "user" | "manual" }
|
||||
>();
|
||||
if (virtualApiKeyIds.length > 0) {
|
||||
const details = await db
|
||||
.select({
|
||||
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
|
||||
name: virtualApiKeys.name,
|
||||
lastChars: virtualApiKeys.lastChars,
|
||||
kind: virtualApiKeys.kind
|
||||
})
|
||||
.from(virtualApiKeys)
|
||||
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
|
||||
for (const k of details) {
|
||||
detailsMap.set(k.virtualApiKeyId, {
|
||||
name: k.name,
|
||||
lastChars: k.lastChars,
|
||||
kind: k.kind
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const topVirtualApiKeys = virtualApiKeyTotalsRaw.map((r) => {
|
||||
const details = r.virtualApiKeyId
|
||||
? detailsMap.get(r.virtualApiKeyId)
|
||||
: undefined;
|
||||
return {
|
||||
virtualApiKeyId: r.virtualApiKeyId,
|
||||
name: details?.name ?? null,
|
||||
lastChars: details?.lastChars ?? null,
|
||||
kind: details?.kind ?? null,
|
||||
requests: r.requests,
|
||||
totalTokens: r.totalTokens,
|
||||
costUsd: r.costUsd
|
||||
};
|
||||
});
|
||||
|
||||
const virtualApiKeyCostTotals = new Map<string, number>();
|
||||
const virtualApiKeyTokenTotals = new Map<string, number>();
|
||||
for (const row of virtualApiKeyByDay) {
|
||||
const key = row.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY;
|
||||
virtualApiKeyCostTotals.set(
|
||||
key,
|
||||
(virtualApiKeyCostTotals.get(key) ?? 0) + row.cost
|
||||
);
|
||||
virtualApiKeyTokenTotals.set(
|
||||
key,
|
||||
(virtualApiKeyTokenTotals.get(key) ?? 0) + row.tokens
|
||||
);
|
||||
}
|
||||
const topVirtualApiKeysByCost = pickTopNKeys(virtualApiKeyCostTotals);
|
||||
const topVirtualApiKeysByTokens = pickTopNKeys(virtualApiKeyTokenTotals);
|
||||
|
||||
const virtualApiKeyCostPerDay = bucketTopNPerDay(
|
||||
virtualApiKeyByDay.map((r) => ({
|
||||
day: r.day,
|
||||
key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
|
||||
value: r.cost
|
||||
})),
|
||||
topVirtualApiKeysByCost
|
||||
);
|
||||
const virtualApiKeyTokensPerDay = bucketTopNPerDay(
|
||||
virtualApiKeyByDay.map((r) => ({
|
||||
day: r.day,
|
||||
key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
|
||||
value: r.tokens
|
||||
})),
|
||||
topVirtualApiKeysByTokens
|
||||
);
|
||||
|
||||
return {
|
||||
topVirtualApiKeys,
|
||||
virtualApiKeyCostPerDay,
|
||||
virtualApiKeyTokensPerDay
|
||||
};
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/ai/usage/virtual-api-keys",
|
||||
description:
|
||||
"Query the AI usage analytics virtual API key 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 QueryAiUsageVirtualApiKeysResponse = Awaited<
|
||||
ReturnType<typeof query>
|
||||
>;
|
||||
|
||||
export async function queryAiUsageVirtualApiKeys(
|
||||
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<QueryAiUsageVirtualApiKeysResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message:
|
||||
"AI usage virtual API key breakdown retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export function queryRequest(data: Q) {
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(getWhere(data))
|
||||
.orderBy(desc(requestAuditLog.timestamp));
|
||||
.orderBy(desc(requestAuditLog.timestamp), desc(requestAuditLog.id));
|
||||
}
|
||||
|
||||
async function enrichWithResourceDetails(
|
||||
|
||||
@@ -94,6 +94,72 @@ export type QueryAccessAuditLogResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryAiSessionLogResponse = {
|
||||
log: {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
orgId: string | null;
|
||||
providerId: number | null;
|
||||
providerName: string | null;
|
||||
providerType: string | null;
|
||||
capability: string;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceType: "public" | "site" | null;
|
||||
userId: string | null;
|
||||
userEmail: string | null;
|
||||
virtualApiKeyId: string | null;
|
||||
virtualApiKeyName: string | null;
|
||||
virtualApiKeyLastChars: string | null;
|
||||
requestedModel: string | null;
|
||||
isStream: boolean;
|
||||
requestBody: string | null;
|
||||
responseBody: string | null;
|
||||
normalizedRequest: string | null;
|
||||
normalizedResponse: string | null;
|
||||
truncated: boolean;
|
||||
statusCode: number | null;
|
||||
createdAt: number;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
completionTokens: number;
|
||||
reasoningTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number | null;
|
||||
estimated: boolean;
|
||||
} | null;
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
filterAttributes: {
|
||||
providers: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
users: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
}[];
|
||||
virtualApiKeys: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
lastChars: string | null;
|
||||
}[];
|
||||
models: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryConnectionAuditLogResponse = {
|
||||
log: {
|
||||
sessionId: string;
|
||||
|
||||
Reference in New Issue
Block a user