send, process, store, display virtual api key ai information in usage and sessions

This commit is contained in:
Owen
2026-08-12 11:20:25 -04:00
parent 355294a2d4
commit bed5817da3
20 changed files with 849 additions and 68 deletions
+10
View File
@@ -1989,6 +1989,14 @@
"aiUsageUserCost": "User Cost", "aiUsageUserCost": "User Cost",
"aiUsageUserTokenUsage": "User Token Usage", "aiUsageUserTokenUsage": "User Token Usage",
"aiUsageUnknownUser": "Unknown user", "aiUsageUnknownUser": "Unknown user",
"aiUsageVirtualApiKeysTab": "Virtual API Keys",
"aiUsageFilterVirtualApiKey": "Virtual API Key",
"aiUsageFilterAllVirtualApiKeys": "All Virtual API Keys",
"aiUsageTopVirtualApiKeys": "Top Virtual API Keys",
"aiUsageVirtualApiKeyCost": "Virtual API Key Cost",
"aiUsageVirtualApiKeyTokenUsage": "Virtual API Key Token Usage",
"aiUsageUnknownVirtualApiKey": "No virtual API key",
"aiUsageUnnamedVirtualApiKey": "Unnamed key",
"aiUsageLoading": "Loading...", "aiUsageLoading": "Loading...",
"aiUsageNoData": "No data", "aiUsageNoData": "No data",
"resourceBudgetSettings": "Budget", "resourceBudgetSettings": "Budget",
@@ -3457,6 +3465,8 @@
"provider": "Provider", "provider": "Provider",
"capability": "Capability", "capability": "Capability",
"model": "Model", "model": "Model",
"virtualApiKey": "Virtual API Key",
"noVirtualApiKey": "No virtual API key",
"stream": "Stream", "stream": "Stream",
"streaming": "Streaming", "streaming": "Streaming",
"nonStreaming": "Non-streaming", "nonStreaming": "Non-streaming",
+24 -6
View File
@@ -1819,18 +1819,22 @@ export const aiUsageRecords = pgTable(
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId") providerId: integer("providerId")
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "set null" }),
resourceId: integer("resourceId").references( resourceId: integer("resourceId").references(
() => resources.resourceId, () => resources.resourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
siteResourceId: integer("siteResourceId").references( siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId, () => siteResources.siteResourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
userId: varchar("userId").references(() => users.userId, { userId: varchar("userId").references(() => users.userId, {
onDelete: "set null" onDelete: "set null"
}), }),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same // Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown // request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog // alongside the session transcript. Not a DB-level FK - aiSessionLog
@@ -1870,6 +1874,11 @@ export const aiUsageRecords = pgTable(
t.userId, t.userId,
t.createdAt t.createdAt
), ),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId) index("idx_ai_usage_records_session").on(t.sessionId)
] ]
); );
@@ -1927,19 +1936,23 @@ export const aiSessionLog = pgTable(
}), }),
providerId: integer("providerId") providerId: integer("providerId")
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "set null" }),
capability: varchar("capability").notNull(), capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references( resourceId: integer("resourceId").references(
() => resources.resourceId, () => resources.resourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
siteResourceId: integer("siteResourceId").references( siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId, () => siteResources.siteResourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
userId: varchar("userId").references(() => users.userId, { userId: varchar("userId").references(() => users.userId, {
onDelete: "set null" onDelete: "set null"
}), }),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"), requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false), isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"), requestBody: text("requestBody"),
@@ -1979,6 +1992,11 @@ export const aiSessionLog = pgTable(
t.userId, t.userId,
t.createdAt t.createdAt
), ),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId) index("idx_ai_session_log_session").on(t.sessionId)
] ]
); );
+24 -6
View File
@@ -1807,18 +1807,22 @@ export const aiUsageRecords = sqliteTable(
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId") providerId: integer("providerId")
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "set null" }),
resourceId: integer("resourceId").references( resourceId: integer("resourceId").references(
() => resources.resourceId, () => resources.resourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
siteResourceId: integer("siteResourceId").references( siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId, () => siteResources.siteResourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
onDelete: "set null" onDelete: "set null"
}), }),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same // Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown // request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog // alongside the session transcript. Not a DB-level FK - aiSessionLog
@@ -1860,6 +1864,11 @@ export const aiUsageRecords = sqliteTable(
t.userId, t.userId,
t.createdAt t.createdAt
), ),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId) index("idx_ai_usage_records_session").on(t.sessionId)
] ]
); );
@@ -1917,19 +1926,23 @@ export const aiSessionLog = sqliteTable(
}), }),
providerId: integer("providerId") providerId: integer("providerId")
.notNull() .notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }), .references(() => aiProviders.providerId, { onDelete: "set null" }),
capability: text("capability").notNull(), capability: text("capability").notNull(),
resourceId: integer("resourceId").references( resourceId: integer("resourceId").references(
() => resources.resourceId, () => resources.resourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
siteResourceId: integer("siteResourceId").references( siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId, () => siteResources.siteResourceId,
{ onDelete: "cascade" } { onDelete: "set null" }
), ),
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
onDelete: "set null" onDelete: "set null"
}), }),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"), requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" }) isStream: integer("isStream", { mode: "boolean" })
.notNull() .notNull()
@@ -1973,6 +1986,11 @@ export const aiSessionLog = sqliteTable(
t.userId, t.userId,
t.createdAt t.createdAt
), ),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId) index("idx_ai_session_log_session").on(t.sessionId)
] ]
); );
+30 -6
View File
@@ -1,4 +1,14 @@
import { and, eq, gte, inArray, isNull, or, sql, SQL, type InferInsertModel } from "drizzle-orm"; import {
and,
eq,
gte,
inArray,
isNull,
or,
sql,
SQL,
type InferInsertModel
} from "drizzle-orm";
import { import {
AiBudget, AiBudget,
aiBudgetBreachEvents, aiBudgetBreachEvents,
@@ -424,6 +434,7 @@ export type UsageRecordInput = {
resourceId: number | null; resourceId: number | null;
siteResourceId: number | null; siteResourceId: number | null;
userId: string | null; userId: string | null;
virtualApiKeyId: string | null;
requestedModel: string; requestedModel: string;
usage: AiUsage; usage: AiUsage;
costUsd: number | null; costUsd: number | null;
@@ -460,7 +471,10 @@ async function flushUsageRecords() {
isUsageFlushInProgress = true; isUsageFlushInProgress = true;
const recordsToWrite = usageRecordBuffer.splice(0, usageRecordBuffer.length); const recordsToWrite = usageRecordBuffer.splice(
0,
usageRecordBuffer.length
);
try { try {
// Use a transaction to ensure all inserts succeed or fail together // Use a transaction to ensure all inserts succeed or fail together
@@ -472,16 +486,25 @@ async function flushUsageRecords() {
await tx.insert(aiUsageRecords).values(batch); await tx.insert(aiUsageRecords).values(batch);
} }
}); });
logger.debug(`Flushed ${recordsToWrite.length} AI usage records to database`); logger.debug(
`Flushed ${recordsToWrite.length} AI usage records to database`
);
} catch (error) { } catch (error) {
logger.error("Error flushing AI usage records:", error); logger.error("Error flushing AI usage records:", error);
// On transaction error, put records back at the front of the buffer // On transaction error, put records back at the front of the buffer
// to retry, but only if the buffer isn't too large // to retry, but only if the buffer isn't too large
if (usageRecordBuffer.length < USAGE_MAX_BUFFER_SIZE - recordsToWrite.length) { if (
usageRecordBuffer.length <
USAGE_MAX_BUFFER_SIZE - recordsToWrite.length
) {
usageRecordBuffer.unshift(...recordsToWrite); usageRecordBuffer.unshift(...recordsToWrite);
logger.info(`Re-queued ${recordsToWrite.length} AI usage records for retry`); logger.info(
`Re-queued ${recordsToWrite.length} AI usage records for retry`
);
} else { } else {
logger.error(`Buffer full, dropped ${recordsToWrite.length} AI usage records`); logger.error(
`Buffer full, dropped ${recordsToWrite.length} AI usage records`
);
} }
} finally { } finally {
isUsageFlushInProgress = false; isUsageFlushInProgress = false;
@@ -544,6 +567,7 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
resourceId: input.resourceId, resourceId: input.resourceId,
siteResourceId: input.siteResourceId, siteResourceId: input.siteResourceId,
userId: input.userId, userId: input.userId,
virtualApiKeyId: input.virtualApiKeyId,
sessionId: input.sessionId, sessionId: input.sessionId,
requestedModel: input.requestedModel, requestedModel: input.requestedModel,
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
+4
View File
@@ -180,6 +180,7 @@ export function logAiSession(data: {
resourceId: number | null; resourceId: number | null;
siteResourceId: number | null; siteResourceId: number | null;
requestUserId: string | null; requestUserId: string | null;
virtualApiKeyId: string | null;
}): void { }): void {
(async () => { (async () => {
try { try {
@@ -237,6 +238,9 @@ export function logAiSession(data: {
resourceId: data.resourceId ?? undefined, resourceId: data.resourceId ?? undefined,
siteResourceId: data.siteResourceId ?? undefined, siteResourceId: data.siteResourceId ?? undefined,
userId: sanitizeString(data.requestUserId ?? undefined), userId: sanitizeString(data.requestUserId ?? undefined),
virtualApiKeyId: sanitizeString(
data.virtualApiKeyId ?? undefined
),
requestedModel: sanitizeString(data.requestedModel), requestedModel: sanitizeString(data.requestedModel),
isStream: data.isStream, isStream: data.isStream,
requestBody: sanitizeString(requestBodyText.value), requestBody: sanitizeString(requestBodyText.value),
+49 -19
View File
@@ -161,6 +161,15 @@ export type RequestUser = {
roleIds: number[]; roleIds: number[];
}; };
// Identity resolved for a gateway request: the app/session or virtual-API-key
// user (if any) plus the virtual API key that authenticated the request (if
// any) - a manual virtual API key with no associated user has a
// virtualApiKeyId but no user.
export type RequestIdentity = {
user: RequestUser | null;
virtualApiKeyId: string | null;
};
// Identity headers forwarded to the upstream inference endpoint when the // Identity headers forwarded to the upstream inference endpoint when the
// requesting user is known. Omitted entirely (not sent empty) when we // requesting user is known. Omitted entirely (not sent empty) when we
// couldn't resolve a user for the request. // couldn't resolve a user for the request.
@@ -223,10 +232,12 @@ async function resolveRequestUser(
req: Request, req: Request,
resourceId: number | null, resourceId: number | null,
orgId: string | null orgId: string | null
): Promise<RequestUser | null> { ): Promise<RequestIdentity> {
// Public inference: identity comes from Badger via Remote-* only when the // Public inference: identity comes from Badger via Remote-* only when the
// Traefik trust header proves the request passed verify-session (VAK). // Traefik trust header proves the request passed verify-session (VAK).
if (isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)) { if (isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)) {
const virtualApiKeyId =
getRequestHeader(req, "remote-virtual-api-key-id") || null;
const userId = getRequestHeader(req, "remote-user-id"); const userId = getRequestHeader(req, "remote-user-id");
if (userId) { if (userId) {
const username = getRequestHeader(req, "remote-user") || userId; const username = getRequestHeader(req, "remote-user") || userId;
@@ -236,32 +247,41 @@ async function resolveRequestUser(
const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : []; const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : [];
return { return {
userId, user: {
username, userId,
email: email || null, username,
name: name || null, email: email || null,
role: name: name || null,
role || orgRoles.map((r) => r.roleName).join(", ") || null, role:
roleIds: orgRoles.map((r) => r.roleId) role ||
orgRoles.map((r) => r.roleName).join(", ") ||
null,
roleIds: orgRoles.map((r) => r.roleId)
},
virtualApiKeyId
}; };
} }
// Trusted request with no associated user (manual key without userId). // Trusted request with no associated user (manual key without
// userId) - still attribute usage to the virtual API key itself.
if (resourceId != null) { if (resourceId != null) {
return null; return { user: null, virtualApiKeyId };
} }
} }
// Public inference must come through Badger; do not authorize via app session. // Public inference must come through Badger; do not authorize via app session.
if (resourceId != null) { if (resourceId != null) {
return null; return { user: null, virtualApiKeyId: null };
} }
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME]; const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
if (sessionToken) { if (sessionToken) {
const { session, user } = await validateSessionToken(sessionToken); const { session, user } = await validateSessionToken(sessionToken);
if (session && user) { if (session && user) {
return buildRequestUser(user.userId, orgId); return {
user: await buildRequestUser(user.userId, orgId),
virtualApiKeyId: null
};
} }
} }
@@ -269,7 +289,7 @@ async function resolveRequestUser(
const ip = req.ip; const ip = req.ip;
if (!ip) { if (!ip) {
return null; return { user: null, virtualApiKeyId: null };
} }
const exitNodeRanges = await getExitNodeRanges(); const exitNodeRanges = await getExitNodeRanges();
@@ -277,15 +297,18 @@ async function resolveRequestUser(
isIpInCidr(ip, range) isIpInCidr(ip, range)
); );
if (!inExitNodeRange) { if (!inExitNodeRange) {
return null; return { user: null, virtualApiKeyId: null };
} }
const client = await findClientByIp(ip); const client = await findClientByIp(ip);
if (!client || !client.userId) { if (!client || !client.userId) {
return null; return { user: null, virtualApiKeyId: null };
} }
return buildRequestUser(client.userId, orgId); return {
user: await buildRequestUser(client.userId, orgId),
virtualApiKeyId: null
};
} }
function getRequestHeader(req: Request, name: string): string | undefined { function getRequestHeader(req: Request, name: string): string | undefined {
@@ -608,6 +631,7 @@ export function recordAiGatewayCompletion(args: {
resourceId: number | null; resourceId: number | null;
siteResourceId: number | null; siteResourceId: number | null;
requestUserId: string | null; requestUserId: string | null;
virtualApiKeyId: string | null;
budgets: AiBudget[]; budgets: AiBudget[];
}): void { }): void {
const { const {
@@ -623,6 +647,7 @@ export function recordAiGatewayCompletion(args: {
resourceId, resourceId,
siteResourceId, siteResourceId,
requestUserId, requestUserId,
virtualApiKeyId,
budgets budgets
} = args; } = args;
@@ -668,6 +693,7 @@ export function recordAiGatewayCompletion(args: {
resourceId, resourceId,
siteResourceId, siteResourceId,
userId: requestUserId, userId: requestUserId,
virtualApiKeyId,
requestedModel: model ?? "unknown", requestedModel: model ?? "unknown",
usage, usage,
costUsd: cost?.totalCost ?? null, costUsd: cost?.totalCost ?? null,
@@ -699,7 +725,8 @@ export function recordAiGatewayCompletion(args: {
orgId, orgId,
resourceId, resourceId,
siteResourceId, siteResourceId,
requestUserId requestUserId,
virtualApiKeyId
}); });
} }
@@ -770,7 +797,7 @@ export async function handleAiGatewayProxy(
const requestedModel = def.extractModel(req); const requestedModel = def.extractModel(req);
const [requestUser, selection] = await Promise.all([ const [identity, selection] = await Promise.all([
resolveRequestUser(req, resourceId, orgId), resolveRequestUser(req, resourceId, orgId),
selectProvider( selectProvider(
capableAttachments, capableAttachments,
@@ -778,6 +805,7 @@ export async function handleAiGatewayProxy(
requestedModel requestedModel
) )
]); ]);
const requestUser = identity.user;
if (requestUser) { if (requestUser) {
logger.debug( logger.debug(
@@ -836,7 +864,8 @@ export async function handleAiGatewayProxy(
resourceId, resourceId,
siteResourceId, siteResourceId,
requestedModel, requestedModel,
budgets: appliedBudgets budgets: appliedBudgets,
virtualApiKeyId: identity.virtualApiKeyId
} }
); );
} }
@@ -987,6 +1016,7 @@ export async function handleAiGatewayProxy(
resourceId, resourceId,
siteResourceId, siteResourceId,
requestUserId: requestUser?.userId ?? null, requestUserId: requestUser?.userId ?? null,
virtualApiKeyId: identity.virtualApiKeyId,
budgets: appliedBudgets budgets: appliedBudgets
}); });
} }
@@ -179,6 +179,7 @@ export async function proxyAiGatewayToSiteTarget(
siteResourceId: number | null; siteResourceId: number | null;
requestedModel: string | undefined; requestedModel: string | undefined;
budgets: AiBudget[]; budgets: AiBudget[];
virtualApiKeyId: string | null;
} }
): Promise<void> { ): Promise<void> {
const providerTargets = await getProviderTargets(provider.providerId); const providerTargets = await getProviderTargets(provider.providerId);
@@ -319,6 +320,7 @@ export async function proxyAiGatewayToSiteTarget(
resourceId: ctx.resourceId, resourceId: ctx.resourceId,
siteResourceId: ctx.siteResourceId, siteResourceId: ctx.siteResourceId,
requestUserId: requestUser?.userId ?? null, requestUserId: requestUser?.userId ?? null,
virtualApiKeyId: ctx.virtualApiKeyId,
budgets: ctx.budgets budgets: ctx.budgets
}); });
} }
@@ -58,7 +58,8 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
.transform(Number) .transform(Number)
.pipe(z.int().positive()) .pipe(z.int().positive())
.optional(), .optional(),
userId: z.string().optional() userId: z.string().optional(),
virtualApiKeyId: z.string().optional()
}); });
export const aiUsageAnalyticsParams = z.object({ export const aiUsageAnalyticsParams = z.object({
@@ -114,6 +115,9 @@ export function buildAiUsageWhere(
) )
: undefined, : undefined,
data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined, data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined,
data.virtualApiKeyId
? eq(aiUsageRecords.virtualApiKeyId, data.virtualApiKeyId)
: undefined,
roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined
); );
} }
+1
View File
@@ -8,3 +8,4 @@ export * from "./queryAiUsageOverview";
export * from "./queryAiUsageProviders"; export * from "./queryAiUsageProviders";
export * from "./queryAiUsageResources"; export * from "./queryAiUsageResources";
export * from "./queryAiUsageUsersRoles"; export * from "./queryAiUsageUsersRoles";
export * from "./queryAiUsageVirtualApiKeys";
+79 -4
View File
@@ -6,6 +6,7 @@ import {
resources, resources,
siteResources, siteResources,
users, users,
virtualApiKeys,
db, db,
primaryDb primaryDb
} from "@server/db"; } from "@server/db";
@@ -67,6 +68,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
.pipe(z.int().positive()) .pipe(z.int().positive())
.optional(), .optional(),
actor: z.string().optional(), actor: z.string().optional(),
virtualApiKeyId: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
isStream: z isStream: z
.union([z.boolean(), z.string()]) .union([z.boolean(), z.string()])
@@ -117,7 +119,9 @@ function getWhere(data: Q) {
data.providerId data.providerId
? eq(aiSessionLog.providerId, data.providerId) ? eq(aiSessionLog.providerId, data.providerId)
: undefined, : undefined,
data.capability ? eq(aiSessionLog.capability, data.capability) : undefined, data.capability
? eq(aiSessionLog.capability, data.capability)
: undefined,
data.resourceId data.resourceId
? or( ? or(
eq(aiSessionLog.resourceId, data.resourceId), eq(aiSessionLog.resourceId, data.resourceId),
@@ -125,9 +129,10 @@ function getWhere(data: Q) {
) )
: undefined, : undefined,
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined, data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
data.model data.virtualApiKeyId
? eq(aiSessionLog.requestedModel, data.model) ? eq(aiSessionLog.virtualApiKeyId, data.virtualApiKeyId)
: undefined, : undefined,
data.model ? eq(aiSessionLog.requestedModel, data.model) : undefined,
data.isStream !== undefined data.isStream !== undefined
? eq(aiSessionLog.isStream, data.isStream) ? eq(aiSessionLog.isStream, data.isStream)
: undefined : undefined
@@ -145,6 +150,7 @@ export function queryAiSession(data: Q) {
resourceId: aiSessionLog.resourceId, resourceId: aiSessionLog.resourceId,
siteResourceId: aiSessionLog.siteResourceId, siteResourceId: aiSessionLog.siteResourceId,
userId: aiSessionLog.userId, userId: aiSessionLog.userId,
virtualApiKeyId: aiSessionLog.virtualApiKeyId,
requestedModel: aiSessionLog.requestedModel, requestedModel: aiSessionLog.requestedModel,
isStream: aiSessionLog.isStream, isStream: aiSessionLog.isStream,
requestBody: aiSessionLog.requestBody, requestBody: aiSessionLog.requestBody,
@@ -182,6 +188,14 @@ async function enrichWithDetails(
) )
]; ];
const virtualApiKeyIds = [
...new Set(
logs
.map((log) => log.virtualApiKeyId)
.filter((id): id is string => id !== null && id !== undefined)
)
];
const providerMap = new Map< const providerMap = new Map<
number, number,
{ name: string | null; type: string | null } { name: string | null; type: string | null }
@@ -254,6 +268,28 @@ async function enrichWithDetails(
} }
} }
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< const usageMap = new Map<
string, string,
{ {
@@ -328,6 +364,12 @@ async function enrichWithDetails(
resourceName, resourceName,
resourceNiceId, resourceNiceId,
userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null, 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 usage: usageMap.get(log.sessionId) ?? null
}; };
}); });
@@ -385,7 +427,8 @@ async function queryUniqueFilterAttributes(
uniqueUsers, uniqueUsers,
uniqueResources, uniqueResources,
uniqueSiteResources, uniqueSiteResources,
uniqueModels uniqueModels,
uniqueVirtualApiKeys
] = await Promise.all([ ] = await Promise.all([
logsDb logsDb
.selectDistinct({ id: aiSessionLog.providerId }) .selectDistinct({ id: aiSessionLog.providerId })
@@ -411,6 +454,11 @@ async function queryUniqueFilterAttributes(
.selectDistinct({ model: aiSessionLog.requestedModel }) .selectDistinct({ model: aiSessionLog.requestedModel })
.from(aiSessionLog) .from(aiSessionLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiSessionLog.virtualApiKeyId })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1) .limit(DISTINCT_LIMIT + 1)
]); ]);
@@ -498,10 +546,37 @@ async function queryUniqueFilterAttributes(
]; ];
} }
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 { return {
providers: sortNamedFilterOptions(providers), providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames), resources: sortNamedFilterOptions(resourcesWithNames),
users: userList, users: userList,
virtualApiKeys: virtualApiKeyList,
models: models.sort() models: models.sort()
}; };
} }
@@ -6,7 +6,8 @@ import {
siteResources, siteResources,
users, users,
roles, roles,
userOrgRoles userOrgRoles,
virtualApiKeys
} from "@server/db"; } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
@@ -43,8 +44,9 @@ const queryAiUsageFilterOptionsParams = z.object({
orgId: z.string() orgId: z.string()
}); });
const queryAiUsageFilterOptionsCombined = const queryAiUsageFilterOptionsCombined = queryAiUsageFilterOptionsQuery.merge(
queryAiUsageFilterOptionsQuery.merge(queryAiUsageFilterOptionsParams); queryAiUsageFilterOptionsParams
);
type Q = z.infer<typeof queryAiUsageFilterOptionsCombined>; type Q = z.infer<typeof queryAiUsageFilterOptionsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>( function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
@@ -73,7 +75,8 @@ async function query(data: Q) {
uniqueModels, uniqueModels,
uniqueResources, uniqueResources,
uniqueSiteResources, uniqueSiteResources,
uniqueUsers uniqueUsers,
uniqueVirtualApiKeys
] = await Promise.all([ ] = await Promise.all([
db db
.selectDistinct({ id: aiUsageRecords.providerId }) .selectDistinct({ id: aiUsageRecords.providerId })
@@ -105,6 +108,13 @@ async function query(data: Q) {
.selectDistinct({ userId: aiUsageRecords.userId }) .selectDistinct({ userId: aiUsageRecords.userId })
.from(aiUsageRecords) .from(aiUsageRecords)
.where(and(baseConditions, not(isNull(aiUsageRecords.userId)))) .where(and(baseConditions, not(isNull(aiUsageRecords.userId))))
.limit(DISTINCT_LIMIT + 1),
db
.selectDistinct({ id: aiUsageRecords.virtualApiKeyId })
.from(aiUsageRecords)
.where(
and(baseConditions, not(isNull(aiUsageRecords.virtualApiKeyId)))
)
.limit(DISTINCT_LIMIT + 1) .limit(DISTINCT_LIMIT + 1)
]); ]);
@@ -120,11 +130,17 @@ async function query(data: Q) {
let providers: Array<{ id: number; name: string | null }> = []; let providers: Array<{ id: number; name: string | null }> = [];
if (providerIds.length > 0) { if (providerIds.length > 0) {
const providerDetails = await db const providerDetails = await db
.select({ providerId: aiProviders.providerId, name: aiProviders.name }) .select({
providerId: aiProviders.providerId,
name: aiProviders.name
})
.from(aiProviders) .from(aiProviders)
.where(inArray(aiProviders.providerId, providerIds)); .where(inArray(aiProviders.providerId, providerIds));
providers = providerDetails.map((p) => ({ id: p.providerId, name: p.name })); providers = providerDetails.map((p) => ({
id: p.providerId,
name: p.name
}));
} }
const resourceIds = uniqueResources const resourceIds = uniqueResources
@@ -155,7 +171,10 @@ async function query(data: Q) {
.where(inArray(siteResources.siteResourceId, siteResourceIds)); .where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = resourcesWithNames.concat( resourcesWithNames = resourcesWithNames.concat(
siteResourceDetails.map((r) => ({ id: r.siteResourceId, name: r.name })) siteResourceDetails.map((r) => ({
id: r.siteResourceId,
name: r.name
}))
); );
} }
@@ -190,11 +209,37 @@ async function query(data: Q) {
roleList = [...roleMap.entries()].map(([id, name]) => ({ id, 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 { return {
providers: sortNamedFilterOptions(providers), providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames), resources: sortNamedFilterOptions(resourcesWithNames),
roles: sortNamedFilterOptions(roleList), roles: sortNamedFilterOptions(roleList),
users: userList, users: userList,
virtualApiKeys: virtualApiKeyList,
models models
}; };
} }
@@ -227,7 +272,9 @@ registry.registerPath({
} }
}); });
export type QueryAiUsageFilterOptionsResponse = Awaited<ReturnType<typeof query>>; export type QueryAiUsageFilterOptionsResponse = Awaited<
ReturnType<typeof query>
>;
export async function queryAiUsageFilterOptions( export async function queryAiUsageFilterOptions(
req: Request, req: Request,
@@ -238,7 +285,10 @@ export async function queryAiUsageFilterOptions(
const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query); const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query);
if (!parsedQuery.success) { if (!parsedQuery.success) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error)) createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
); );
} }
@@ -247,7 +297,10 @@ export async function queryAiUsageFilterOptions(
); );
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error)) createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
); );
} }
@@ -0,0 +1,222 @@
import { db, 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 db
.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 db
.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")
);
}
}
+8
View File
@@ -110,6 +110,9 @@ export type QueryAiSessionLogResponse = {
resourceType: "public" | "site" | null; resourceType: "public" | "site" | null;
userId: string | null; userId: string | null;
userEmail: string | null; userEmail: string | null;
virtualApiKeyId: string | null;
virtualApiKeyName: string | null;
virtualApiKeyLastChars: string | null;
requestedModel: string | null; requestedModel: string | null;
isStream: boolean; isStream: boolean;
requestBody: string | null; requestBody: string | null;
@@ -148,6 +151,11 @@ export type QueryAiSessionLogResponse = {
id: string; id: string;
email: string | null; email: string | null;
}[]; }[];
virtualApiKeys: {
id: string;
name: string | null;
lastChars: string | null;
}[];
models: string[]; models: string[];
}; };
}; };
+17 -4
View File
@@ -96,6 +96,10 @@ export type VerifyUserResponse = {
userData?: BasicUserData; userData?: BasicUserData;
pangolinVersion?: string; pangolinVersion?: string;
dontStripSession?: boolean; dontStripSession?: boolean;
// Set independently of userData so a manual virtual API key with no
// associated user still gets attributed to the key that authenticated
// the request (see the mode === "inference" branch below).
virtualApiKeyId?: string;
}; };
export async function verifyResourceSession( export async function verifyResourceSession(
@@ -401,7 +405,12 @@ export async function verifyResourceSession(
parsedBody.data parsedBody.data
); );
return allowed(res, vakUserData, dontStripSession); return allowed(
res,
vakUserData,
dontStripSession,
key.virtualApiKeyId
);
} }
} }
@@ -987,16 +996,20 @@ async function notAllowed(
function allowed( function allowed(
res: Response, res: Response,
userData?: BasicUserData, userData?: BasicUserData,
dontStripSession?: boolean dontStripSession?: boolean,
virtualApiKeyId?: string
) { ) {
const baseData = const baseData =
userData !== undefined && userData !== null userData !== undefined && userData !== null
? { valid: true, ...userData, pangolinVersion: APP_VERSION } ? { valid: true, ...userData, pangolinVersion: APP_VERSION }
: { valid: true, pangolinVersion: APP_VERSION }; : { valid: true, pangolinVersion: APP_VERSION };
const withVirtualApiKey = virtualApiKeyId
? { ...baseData, virtualApiKeyId }
: baseData;
const data = { const data = {
data: dontStripSession data: dontStripSession
? { ...baseData, dontStripSession: true } ? { ...withVirtualApiKey, dontStripSession: true }
: baseData, : withVirtualApiKey,
success: true, success: true,
error: false, error: false,
message: "Access allowed", message: "Access allowed",
+7
View File
@@ -1537,6 +1537,13 @@ authenticated.get(
logs.queryAiUsageUsersRoles logs.queryAiUsageUsersRoles
); );
authenticated.get(
"/org/:orgId/logs/ai/usage/virtual-api-keys",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageVirtualApiKeys
);
authenticated.get( authenticated.get(
"/org/:orgId/blueprints", "/org/:orgId/blueprints",
verifyOrgAccess, verifyOrgAccess,
+7
View File
@@ -1580,6 +1580,13 @@ authenticated.get(
logs.queryAiUsageUsersRoles logs.queryAiUsageUsersRoles
); );
authenticated.get(
"/org/:orgId/logs/ai/usage/virtual-api-keys",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageVirtualApiKeys
);
authenticated.get( authenticated.get(
"/org/:orgId/logs/analytics", "/org/:orgId/logs/analytics",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
+100 -3
View File
@@ -12,6 +12,7 @@ import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref"; import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries"; import { logQueries } from "@app/lib/queries";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
@@ -50,6 +51,7 @@ export default function AiSessionLogsPage() {
capability?: string; capability?: string;
resourceId?: string; resourceId?: string;
actor?: string; actor?: string;
virtualApiKeyId?: string;
model?: string; model?: string;
isStream?: string; isStream?: string;
}>({ }>({
@@ -57,6 +59,7 @@ export default function AiSessionLogsPage() {
capability: searchParams.get("capability") || undefined, capability: searchParams.get("capability") || undefined,
resourceId: searchParams.get("resourceId") || undefined, resourceId: searchParams.get("resourceId") || undefined,
actor: searchParams.get("actor") || undefined, actor: searchParams.get("actor") || undefined,
virtualApiKeyId: searchParams.get("virtualApiKeyId") || undefined,
model: searchParams.get("model") || undefined, model: searchParams.get("model") || undefined,
isStream: searchParams.get("isStream") || undefined isStream: searchParams.get("isStream") || undefined
}); });
@@ -135,6 +138,7 @@ export default function AiSessionLogsPage() {
providers: [], providers: [],
resources: [], resources: [],
users: [], users: [],
virtualApiKeys: [],
models: [] models: []
}; };
@@ -358,7 +362,9 @@ export default function AiSessionLogsPage() {
}, },
cell: ({ row }) => { cell: ({ row }) => {
if (!row.original.resourceNiceId) { if (!row.original.resourceNiceId) {
return <span className="text-xs text-muted-foreground">-</span>; return (
<span className="text-xs text-muted-foreground">-</span>
);
} }
return ( return (
<Link <Link
@@ -453,13 +459,67 @@ export default function AiSessionLogsPage() {
</span> </span>
); );
} }
},
{
accessorKey: "virtualApiKeyId",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.virtualApiKeys.map(
(key) => ({
value: key.id,
label:
key.name ??
(key.lastChars
? formatVirtualApiKeyPreview(
key.id,
key.lastChars
)
: key.id)
})
)}
selectedValue={filters.virtualApiKeyId}
onValueChange={(value) =>
handleFilterChange("virtualApiKeyId", value)
}
label={t("virtualApiKey")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
if (!row.original.virtualApiKeyId) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<div className="flex flex-col min-w-0">
<span className="truncate">
{row.original.virtualApiKeyName ??
t("aiUsageUnnamedVirtualApiKey")}
</span>
{row.original.virtualApiKeyLastChars && (
<span className="text-xs text-muted-foreground truncate">
{formatVirtualApiKeyPreview(
row.original.virtualApiKeyId,
row.original.virtualApiKeyLastChars
)}
</span>
)}
</div>
);
}
} }
]; ];
const renderExpandedRow = (row: any) => { const renderExpandedRow = (row: any) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-xs">
<div> <div>
<strong>{t("aiSessionId")}</strong> <strong>{t("aiSessionId")}</strong>
<p className="text-muted-foreground mt-1 break-all"> <p className="text-muted-foreground mt-1 break-all">
@@ -490,6 +550,30 @@ export default function AiSessionLogsPage() {
: "N/A"} : "N/A"}
</p> </p>
</div> </div>
<div>
<strong>{t("virtualApiKey")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.virtualApiKeyId ? (
<>
{row.virtualApiKeyName ??
t("aiUsageUnnamedVirtualApiKey")}
{row.virtualApiKeyLastChars && (
<>
{" "}
(
{formatVirtualApiKeyPreview(
row.virtualApiKeyId,
row.virtualApiKeyLastChars
)}
)
</>
)}
</>
) : (
t("noVirtualApiKey")
)}
</p>
</div>
</div> </div>
{row.usage && ( {row.usage && (
<div> <div>
@@ -599,15 +683,25 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
]; ];
const actors = ["alice@example.com", "bob@example.com", null]; const actors = ["alice@example.com", "bob@example.com", null];
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"]; const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
const virtualApiKeysSample = [
{ id: "vak00001", name: "CI pipeline", lastChars: "ab12" },
{ id: "vak00002", name: null, lastChars: "cd34" },
null
];
const now = Date.now(); const now = Date.now();
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000; const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
return Array.from({ length: 10 }, (_, i) => { return Array.from({ length: 10 }, (_, i) => {
const provider = providers[Math.floor(Math.random() * providers.length)]; const provider =
providers[Math.floor(Math.random() * providers.length)];
const resource = const resource =
resourcesSample[Math.floor(Math.random() * resourcesSample.length)]; resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
const actor = actors[Math.floor(Math.random() * actors.length)]; const actor = actors[Math.floor(Math.random() * actors.length)];
const virtualApiKey =
virtualApiKeysSample[
Math.floor(Math.random() * virtualApiKeysSample.length)
];
return { return {
id: i, id: i,
@@ -625,6 +719,9 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
resourceType: "public", resourceType: "public",
userId: actor ? `user-${i}` : null, userId: actor ? `user-${i}` : null,
userEmail: actor, userEmail: actor,
virtualApiKeyId: virtualApiKey?.id ?? null,
virtualApiKeyName: virtualApiKey?.name ?? null,
virtualApiKeyLastChars: virtualApiKey?.lastChars ?? null,
requestedModel: models[Math.floor(Math.random() * models.length)], requestedModel: models[Math.floor(Math.random() * models.length)],
isStream: Math.random() > 0.5, isStream: Math.random() > 0.5,
requestBody: null, requestBody: null,
+29 -3
View File
@@ -29,6 +29,8 @@ import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab"; import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
import { RolesTab } from "./ai-usage-analytics/RolesTab"; import { RolesTab } from "./ai-usage-analytics/RolesTab";
import { UsersTab } from "./ai-usage-analytics/UsersTab"; import { UsersTab } from "./ai-usage-analytics/UsersTab";
import { VirtualApiKeysTab } from "./ai-usage-analytics/VirtualApiKeysTab";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
export type AiUsageAnalyticsDataProps = { export type AiUsageAnalyticsDataProps = {
orgId: string; orgId: string;
@@ -140,13 +142,20 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
value: u.id, value: u.id,
label: u.email ?? u.id label: u.email ?? u.id
})); }));
const virtualApiKeyOptions = (filterOptions?.virtualApiKeys ?? []).map(
(k) => ({
value: k.id,
label: k.name ?? formatVirtualApiKeyPreview(k.id, k.lastChars)
})
);
const tabs: TabItem[] = [ const tabs: TabItem[] = [
{ title: t("aiUsageTabOverview"), href: "#" }, { title: t("aiUsageTabOverview"), href: "#" },
{ title: t("aiUsageTabProviders"), href: "#" }, { title: t("aiUsageTabProviders"), href: "#" },
{ title: t("aiUsageTabResources"), href: "#" }, { title: t("aiUsageTabResources"), href: "#" },
{ title: t("aiUsageRolesTab"), href: "#" }, { title: t("aiUsageRolesTab"), href: "#" },
{ title: t("aiUsageUsersTab"), href: "#" } { title: t("aiUsageUsersTab"), href: "#" },
{ title: t("aiUsageVirtualApiKeysTab"), href: "#" }
]; ];
return ( return (
@@ -180,7 +189,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
value={filters.providerId?.toString()} value={filters.providerId?.toString()}
options={providerOptions} options={providerOptions}
placeholder={t("aiUsageFilterAllProviders")} placeholder={t("aiUsageFilterAllProviders")}
onValueChange={(v) => setFilter("providerId", v)} onValueChange={(v) =>
setFilter("providerId", v)
}
/> />
<FilterSelect <FilterSelect
id="model" id="model"
@@ -196,7 +207,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
value={filters.resourceId?.toString()} value={filters.resourceId?.toString()}
options={resourceOptions} options={resourceOptions}
placeholder={t("aiUsageFilterAllResources")} placeholder={t("aiUsageFilterAllResources")}
onValueChange={(v) => setFilter("resourceId", v)} onValueChange={(v) =>
setFilter("resourceId", v)
}
/> />
<FilterSelect <FilterSelect
id="roleId" id="roleId"
@@ -214,6 +227,18 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
placeholder={t("aiUsageFilterAllUsers")} placeholder={t("aiUsageFilterAllUsers")}
onValueChange={(v) => setFilter("userId", v)} onValueChange={(v) => setFilter("userId", v)}
/> />
<FilterSelect
id="virtualApiKeyId"
label={t("aiUsageFilterVirtualApiKey")}
value={filters.virtualApiKeyId}
options={virtualApiKeyOptions}
placeholder={t(
"aiUsageFilterAllVirtualApiKeys"
)}
onValueChange={(v) =>
setFilter("virtualApiKeyId", v)
}
/>
{!isEmptySearchParams && ( {!isEmptySearchParams && (
<Button <Button
@@ -259,6 +284,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
<ResourcesTab orgId={props.orgId} filters={filters} /> <ResourcesTab orgId={props.orgId} filters={filters} />
<RolesTab orgId={props.orgId} filters={filters} /> <RolesTab orgId={props.orgId} filters={filters} />
<UsersTab orgId={props.orgId} filters={filters} /> <UsersTab orgId={props.orgId} filters={filters} />
<VirtualApiKeysTab orgId={props.orgId} filters={filters} />
</HorizontalTabs> </HorizontalTabs>
</div> </div>
); );
@@ -0,0 +1,108 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import { ToggleableTrendChart } from "./ToggleableTrendChart";
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
import { buildSeriesFromData, formatCost } from "./shared";
type VirtualApiKeysTabProps = {
orgId: string;
filters: AiUsageAnalyticsFilters;
};
const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown";
export function VirtualApiKeysTab(props: VirtualApiKeysTabProps) {
const t = useTranslations();
const { data, isLoading } = useQuery(
aiUsageAnalyticsQueries.virtualApiKeys({
orgId: props.orgId,
filters: props.filters
})
);
const labelByKey = new Map<string, string>();
for (const k of data?.topVirtualApiKeys ?? []) {
if (k.virtualApiKeyId) {
labelByKey.set(k.virtualApiKeyId, k.name ?? k.virtualApiKeyId);
}
}
const virtualApiKeyLabelFor = (key: string) =>
key === UNKNOWN_VIRTUAL_API_KEY_KEY
? t("aiUsageUnknownVirtualApiKey")
: (labelByKey.get(key) ?? key);
const virtualApiKeyCostSeries = buildSeriesFromData(
data?.virtualApiKeyCostPerDay ?? [],
virtualApiKeyLabelFor,
t("aiUsageOther")
);
const virtualApiKeyTokensSeries = buildSeriesFromData(
data?.virtualApiKeyTokensPerDay ?? [],
virtualApiKeyLabelFor,
t("aiUsageOther")
);
const topVirtualApiKeys: TopEntity[] = (data?.topVirtualApiKeys ?? []).map(
(k) => ({
key: k.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
label: k.virtualApiKeyId
? (k.name ?? t("aiUsageUnnamedVirtualApiKey"))
: t("aiUsageUnknownVirtualApiKey"),
sublabel:
k.virtualApiKeyId && k.lastChars
? formatVirtualApiKeyPreview(k.virtualApiKeyId, k.lastChars)
: undefined,
requests: k.requests,
totalTokens: k.totalTokens,
costUsd: k.costUsd
})
);
return (
<div className="flex flex-col gap-5">
<Card>
<CardHeader>
<h3 className="font-semibold">
{t("aiUsageTopVirtualApiKeys")}
</h3>
</CardHeader>
<CardContent>
<TopEntitiesList
entities={topVirtualApiKeys}
isLoading={isLoading}
nameColumnLabel={t("aiUsageFilterVirtualApiKey")}
/>
</CardContent>
</Card>
<div className="grid lg:grid-cols-2 gap-5">
<Card>
<CardContent className="p-6">
<ToggleableTrendChart
title={t("aiUsageVirtualApiKeyCost")}
data={data?.virtualApiKeyCostPerDay ?? []}
series={virtualApiKeyCostSeries}
isLoading={isLoading}
valueFormatter={(v) => formatCost(v)}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<ToggleableTrendChart
title={t("aiUsageVirtualApiKeyTokenUsage")}
data={data?.virtualApiKeyTokensPerDay ?? []}
series={virtualApiKeyTokensSeries}
isLoading={isLoading}
/>
</CardContent>
</Card>
</div>
</div>
);
}
+60 -6
View File
@@ -12,7 +12,8 @@ import type {
QueryAiUsageOverviewResponse, QueryAiUsageOverviewResponse,
QueryAiUsageProvidersResponse, QueryAiUsageProvidersResponse,
QueryAiUsageResourcesResponse, QueryAiUsageResourcesResponse,
QueryAiUsageUsersRolesResponse QueryAiUsageUsersRolesResponse,
QueryAiUsageVirtualApiKeysResponse
} from "@server/routers/auditLogs"; } from "@server/routers/auditLogs";
import type { import type {
QueryAccessAuditLogResponse, QueryAccessAuditLogResponse,
@@ -954,7 +955,8 @@ export const aiUsageAnalyticsFiltersSchema = z.object({
model: z.string().optional().catch(undefined), model: z.string().optional().catch(undefined),
resourceId: z.coerce.number().optional().catch(undefined), resourceId: z.coerce.number().optional().catch(undefined),
roleId: z.coerce.number().optional().catch(undefined), roleId: z.coerce.number().optional().catch(undefined),
userId: z.string().optional().catch(undefined) userId: z.string().optional().catch(undefined),
virtualApiKeyId: z.string().optional().catch(undefined)
}); });
export type AiUsageAnalyticsFilters = z.output< export type AiUsageAnalyticsFilters = z.output<
@@ -1086,6 +1088,7 @@ export const aiSessionLogsFiltersSchema = z.object({
capability: z.string().optional().catch(undefined), capability: z.string().optional().catch(undefined),
resourceId: z.string().optional().catch(undefined), resourceId: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined), actor: z.string().optional().catch(undefined),
virtualApiKeyId: z.string().optional().catch(undefined),
model: z.string().optional().catch(undefined), model: z.string().optional().catch(undefined),
isStream: z.string().optional().catch(undefined) isStream: z.string().optional().catch(undefined)
}); });
@@ -1284,7 +1287,12 @@ export const aiUsageAnalyticsQueries = {
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">; filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const, queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"FILTERS",
filters
] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<QueryAiUsageFilterOptionsResponse> AxiosResponse<QueryAiUsageFilterOptionsResponse>
@@ -1304,7 +1312,12 @@ export const aiUsageAnalyticsQueries = {
filters: AiUsageAnalyticsFilters; filters: AiUsageAnalyticsFilters;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const, queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"OVERVIEW",
filters
] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<QueryAiUsageOverviewResponse> AxiosResponse<QueryAiUsageOverviewResponse>
@@ -1330,7 +1343,12 @@ export const aiUsageAnalyticsQueries = {
filters: AiUsageAnalyticsFilters; filters: AiUsageAnalyticsFilters;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const, queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"PROVIDERS",
filters
] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<QueryAiUsageProvidersResponse> AxiosResponse<QueryAiUsageProvidersResponse>
@@ -1356,7 +1374,12 @@ export const aiUsageAnalyticsQueries = {
filters: AiUsageAnalyticsFilters; filters: AiUsageAnalyticsFilters;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const, queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"RESOURCES",
filters
] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<QueryAiUsageResourcesResponse> AxiosResponse<QueryAiUsageResourcesResponse>
@@ -1403,6 +1426,37 @@ export const aiUsageAnalyticsQueries = {
} }
return false; return false;
} }
}),
virtualApiKeys: ({
orgId,
filters
}: {
orgId: string;
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"VIRTUAL_API_KEYS",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageVirtualApiKeysResponse>
>(`/org/${orgId}/logs/ai/usage/virtual-api-keys`, {
params: filters,
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}) })
}; };