link the usage with the session to display together

This commit is contained in:
Owen
2026-08-11 16:14:46 -04:00
parent c42df737b0
commit f7aca85417
9 changed files with 129 additions and 8 deletions
+3
View File
@@ -3411,6 +3411,9 @@
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.", "aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
"aiSessionViewRaw": "View Raw JSON", "aiSessionViewRaw": "View Raw JSON",
"aiSessionViewChat": "View Chat", "aiSessionViewChat": "View Chat",
"tokens": "Tokens",
"cost": "Cost",
"estimated": "estimated",
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
"logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestLabel": "HTTP Request Log Retention",
"logRetentionRequestDescription": "How long to retain request logs", "logRetentionRequestDescription": "How long to retain request logs",
+9 -1
View File
@@ -1831,6 +1831,13 @@ export const aiUsageRecords = pgTable(
userId: varchar("userId").references(() => users.userId, { userId: varchar("userId").references(() => users.userId, {
onDelete: "set null" onDelete: "set null"
}), }),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: varchar("sessionId"),
requestedModel: varchar("requestedModel").notNull(), requestedModel: varchar("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0), promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0), cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
@@ -1862,7 +1869,8 @@ export const aiUsageRecords = pgTable(
t.orgId, t.orgId,
t.userId, t.userId,
t.createdAt t.createdAt
) ),
index("idx_ai_usage_records_session").on(t.sessionId)
] ]
); );
+9 -1
View File
@@ -1819,6 +1819,13 @@ export const aiUsageRecords = sqliteTable(
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
onDelete: "set null" onDelete: "set null"
}), }),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: text("sessionId"),
requestedModel: text("requestedModel").notNull(), requestedModel: text("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0), promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0), cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
@@ -1852,7 +1859,8 @@ export const aiUsageRecords = sqliteTable(
t.orgId, t.orgId,
t.userId, t.userId,
t.createdAt t.createdAt
) ),
index("idx_ai_usage_records_session").on(t.sessionId)
] ]
); );
+6
View File
@@ -428,6 +428,11 @@ export type UsageRecordInput = {
usage: AiUsage; usage: AiUsage;
costUsd: number | null; costUsd: number | null;
createdAt?: number; createdAt?: number;
// Same id as the aiSessionLog row logged for this request, so the two
// can be joined to show token/cost usage alongside the session
// transcript. Undefined when the session wasn't logged (e.g. session
// log retention disabled for the org).
sessionId?: string;
}; };
type AiUsageRecordInsert = InferInsertModel<typeof aiUsageRecords>; type AiUsageRecordInsert = InferInsertModel<typeof aiUsageRecords>;
@@ -539,6 +544,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,
sessionId: input.sessionId,
requestedModel: input.requestedModel, requestedModel: input.requestedModel,
promptTokens: usage.promptTokens, promptTokens: usage.promptTokens,
cacheReadTokens: usage.cacheReadTokens, cacheReadTokens: usage.cacheReadTokens,
+2 -2
View File
@@ -1,4 +1,3 @@
import { randomUUID } from "crypto";
import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db"; import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db";
import type { InferInsertModel } from "drizzle-orm"; import type { InferInsertModel } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -169,6 +168,7 @@ function truncateBody(value: string): { value: string; truncated: boolean } {
} }
export function logAiSession(data: { export function logAiSession(data: {
sessionId: string;
capability: AiCapability; capability: AiCapability;
provider: AiProvider; provider: AiProvider;
requestedModel: string | undefined; requestedModel: string | undefined;
@@ -230,7 +230,7 @@ export function logAiSession(data: {
} }
sessionLogBuffer.push({ sessionLogBuffer.push({
sessionId: randomUUID(), sessionId: data.sessionId,
orgId: sanitizeString(data.orgId), orgId: sanitizeString(data.orgId),
providerId: data.provider.providerId, providerId: data.provider.providerId,
capability: data.capability, capability: data.capability,
+10 -1
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { import {
@@ -594,6 +595,12 @@ export function recordAiGatewayCompletion(args: {
const pricing = getModelPricing(provider.type as AiProviderType, model); const pricing = getModelPricing(provider.type as AiProviderType, model);
const cost = calculateAiCost(pricing, usage); const cost = calculateAiCost(pricing, usage);
// Shared by the usage record and the session log so the two can be
// joined later to show token/cost usage alongside the transcript -
// generated up front since neither buffered insert's row id is known
// until its next batch flush.
const sessionId = randomUUID();
logger.info("AI gateway request usage", { logger.info("AI gateway request usage", {
capability, capability,
providerId: provider.providerId, providerId: provider.providerId,
@@ -618,7 +625,8 @@ export function recordAiGatewayCompletion(args: {
userId: requestUserId, userId: requestUserId,
requestedModel: model ?? "unknown", requestedModel: model ?? "unknown",
usage, usage,
costUsd: cost?.totalCost ?? null costUsd: cost?.totalCost ?? null,
sessionId
}); });
if (budgets.length > 0) { if (budgets.length > 0) {
@@ -635,6 +643,7 @@ export function recordAiGatewayCompletion(args: {
} }
logAiSession({ logAiSession({
sessionId,
capability, capability,
provider, provider,
requestedModel, requestedModel,
+48 -1
View File
@@ -2,6 +2,7 @@ import {
logsDb, logsDb,
aiSessionLog, aiSessionLog,
aiProviders, aiProviders,
aiUsageRecords,
resources, resources,
siteResources, siteResources,
users, users,
@@ -253,6 +254,51 @@ async function enrichWithDetails(
} }
} }
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 primaryDb
.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) => { return logs.map((log) => {
const provider = providerMap.get(log.providerId); const provider = providerMap.get(log.providerId);
@@ -281,7 +327,8 @@ async function enrichWithDetails(
providerType: provider?.type ?? null, providerType: provider?.type ?? null,
resourceName, resourceName,
resourceNiceId, resourceNiceId,
userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null,
usage: usageMap.get(log.sessionId) ?? null
}; };
}); });
} }
+10
View File
@@ -119,6 +119,16 @@ export type QueryAiSessionLogResponse = {
truncated: boolean; truncated: boolean;
statusCode: number | null; statusCode: number | null;
createdAt: number; createdAt: number;
usage: {
promptTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
completionTokens: number;
reasoningTokens: number;
totalTokens: number;
costUsd: number | null;
estimated: boolean;
} | null;
}[]; }[];
pagination: { pagination: {
total: number; total: number;
+32 -2
View File
@@ -459,7 +459,7 @@ export default function AiSessionLogsPage() {
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 gap-4 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-4 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">
@@ -472,6 +472,26 @@ export default function AiSessionLogsPage() {
{row.statusCode ?? "N/A"} {row.statusCode ?? "N/A"}
</p> </p>
</div> </div>
<div>
<strong>{t("tokens")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage
? `${row.usage.totalTokens.toLocaleString()}${
row.usage.estimated
? ` (${t("estimated")})`
: ""
}`
: "N/A"}
</p>
</div>
<div>
<strong>{t("cost")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage && row.usage.costUsd != null
? `$${row.usage.costUsd.toFixed(4)}`
: "N/A"}
</p>
</div>
</div> </div>
<AiSessionChatView <AiSessionChatView
normalizedRequest={row.normalizedRequest} normalizedRequest={row.normalizedRequest}
@@ -572,7 +592,17 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
statusCode: 200, statusCode: 200,
createdAt: Math.floor( createdAt: Math.floor(
sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs) sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs)
) ),
usage: {
promptTokens: 500,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: 150,
reasoningTokens: 0,
totalTokens: 650,
costUsd: 0.0123,
estimated: false
}
}; };
}); });
} }