mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 23:40:40 +02:00
link the usage with the session to display together
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user