show the sessions in the ui

This commit is contained in:
Owen
2026-08-11 15:50:51 -04:00
parent bc2f291352
commit e3ccc4f8d4
12 changed files with 1544 additions and 0 deletions
+17
View File
@@ -3388,6 +3388,23 @@
"logRetention": "Log Retention", "logRetention": "Log Retention",
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization", "requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
"aiSessionLogs": "AI Gateway Session Logs",
"aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization",
"sidebarLogsAi": "AI Session Logs",
"commandLogsAi": "AI Session Logs",
"provider": "Provider",
"capability": "Capability",
"model": "Model",
"stream": "Stream",
"streaming": "Streaming",
"nonStreaming": "Non-streaming",
"statusCode": "Status Code",
"aiSessionId": "Session ID",
"aiSessionRequest": "Request",
"aiSessionResponse": "Response",
"aiSessionNoData": "No data captured",
"aiSessionCouldNotParse": "(raw, could not parse transcript)",
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
"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",
@@ -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")
);
}
}
+2
View File
@@ -1,3 +1,5 @@
export * from "./queryRequestAuditLog"; export * from "./queryRequestAuditLog";
export * from "./queryRequestAnalytics"; export * from "./queryRequestAnalytics";
export * from "./exportRequestAuditLog"; export * from "./exportRequestAuditLog";
export * from "./queryAiSessionLog";
export * from "./exportAiSessionLog";
@@ -0,0 +1,511 @@
import {
logsDb,
aiSessionLog,
aiProviders,
resources,
siteResources,
users,
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(),
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.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,
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 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));
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);
}
}
return logs.map((log) => {
const provider = providerMap.get(log.providerId);
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
};
});
}
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
] = 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)
]);
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
}))
];
}
return {
providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames),
users: userList
};
}
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")
);
}
}
+47
View File
@@ -94,6 +94,53 @@ export type QueryAccessAuditLogResponse = {
}; };
}; };
export type QueryAiSessionLogResponse = {
log: {
id: number;
sessionId: string;
orgId: string | null;
providerId: number;
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;
requestedModel: string | null;
isStream: boolean;
requestBody: string | null;
responseBody: string | null;
normalizedRequest: string | null;
normalizedResponse: string | null;
truncated: boolean;
statusCode: number | null;
createdAt: number;
}[];
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;
}[];
};
};
export type QueryConnectionAuditLogResponse = { export type QueryConnectionAuditLogResponse = {
log: { log: {
sessionId: string; sessionId: string;
+15
View File
@@ -1487,6 +1487,21 @@ authenticated.get(
logs.exportRequestAuditLogs logs.exportRequestAuditLogs
); );
authenticated.get(
"/org/:orgId/logs/ai",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/export",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
logs.exportAiSessionLogs
);
authenticated.get( authenticated.get(
"/org/:orgId/blueprints", "/org/:orgId/blueprints",
verifyOrgAccess, verifyOrgAccess,
+15
View File
@@ -1530,6 +1530,21 @@ authenticated.get(
logs.exportRequestAuditLogs logs.exportRequestAuditLogs
); );
authenticated.get(
"/org/:orgId/logs/ai",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiSessionLogs
);
authenticated.get(
"/org/:orgId/logs/ai/export",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.exportLogs),
logActionAudit(ActionsEnum.exportLogs),
logs.exportAiSessionLogs
);
authenticated.get( authenticated.get(
"/org/:orgId/logs/analytics", "/org/:orgId/logs/analytics",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "AI Session Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}
+575
View File
@@ -0,0 +1,575 @@
"use client";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { AiSessionChatView } from "@app/components/AiSessionChatView";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { ArrowUpRight, Bot, Waves, User } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import type { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
const capabilityLabels: Record<string, string> = {
openai_chat: "OpenAI Chat Completions",
openai_responses: "OpenAI Responses",
anthropic_messages: "Anthropic Messages",
gemini_generate_content: "Gemini",
google_generate_content: "Vertex AI (Generate Content)",
google_raw_predict: "Vertex AI (Raw Predict)",
bedrock_model_invoke: "Bedrock (Invoke Model)",
bedrock_converse: "Bedrock (Converse)"
};
export default function AiSessionLogsPage() {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { orgId } = useParams();
const searchParams = useSearchParams();
const [isExporting, startTransition] = useTransition();
const [currentPage, setCurrentPage] = useState<number>(0);
const [pageSize, setPageSize] = useStoredPageSize("ai-session-logs", 20);
const [filters, setFilters] = useState<{
providerId?: string;
capability?: string;
resourceId?: string;
actor?: string;
isStream?: string;
}>({
providerId: searchParams.get("providerId") || undefined,
capability: searchParams.get("capability") || undefined,
resourceId: searchParams.get("resourceId") || undefined,
actor: searchParams.get("actor") || undefined,
isStream: searchParams.get("isStream") || undefined
});
const getDefaultDateRange = () => {
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
if (startParam && endParam) {
return {
startDate: { date: new Date(startParam) },
endDate: { date: new Date(endParam) }
};
}
return {
startDate: { date: getSevenDaysAgo() },
endDate: { date: new Date() }
};
};
const [dateRange, setDateRange] = useState<{
startDate: DateTimeValue;
endDate: DateTimeValue;
}>(getDefaultDateRange());
const queryFilters = useMemo(() => {
let timeStart: string | undefined;
let timeEnd: string | undefined;
if (dateRange.startDate?.date) {
const dt = new Date(dateRange.startDate.date);
if (dateRange.startDate.time) {
const [h, m, s] = dateRange.startDate.time
.split(":")
.map(Number);
dt.setHours(h, m, s || 0);
}
timeStart = dt.toISOString();
}
if (dateRange.endDate?.date) {
const dt = new Date(dateRange.endDate.date);
if (dateRange.endDate.time) {
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
dt.setHours(h, m, s || 0);
} else {
const now = new Date();
dt.setHours(
now.getHours(),
now.getMinutes(),
now.getSeconds(),
now.getMilliseconds()
);
}
timeEnd = dt.toISOString();
}
return {
timeStart,
timeEnd,
page: currentPage,
pageSize,
...filters
};
}, [dateRange, currentPage, pageSize, filters]);
const { data, isFetching, isLoading, refetch } = useQuery({
...logQueries.aiSessions({
orgId: orgId as string,
filters: queryFilters
})
});
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
const totalCount = data?.pagination?.total ?? 0;
const filterAttributes = data?.filterAttributes ?? {
providers: [],
resources: [],
users: []
};
const handleDateRangeChange = (
startDate: DateTimeValue,
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
setCurrentPage(0);
updateUrlParamsForAllFilters({
start: startDate.date?.toISOString() || "",
end: endDate.date?.toISOString() || ""
});
};
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
};
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
setCurrentPage(0);
updateUrlParamsForAllFilters(newFilters);
};
const updateUrlParamsForAllFilters = (
newFilters:
| typeof filters
| {
start: string;
end: string;
}
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
router.replace(`?${params.toString()}`, { scroll: false });
};
const exportData = async () => {
try {
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
: undefined,
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
};
const response = await api.get(`/org/${orgId}/logs/ai/export`, {
responseType: "blob",
params
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
const epoch = Math.floor(Date.now() / 1000);
link.setAttribute(
"download",
`ai-session-logs-${orgId}-${epoch}.csv`
);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
} catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({
title: t("error"),
description: apiErrorMessage ?? t("exportError"),
variant: "destructive"
});
}
};
const columns: ColumnDef<any>[] = [
{
accessorKey: "createdAt",
header: ({ column }) => (
<span className="px-2">{t("timestamp")}</span>
),
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleString()}
</div>
);
}
},
{
accessorKey: "providerName",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.providers.map(
(provider) => ({
value: provider.id.toString(),
label: provider.name || "Unnamed Provider"
})
)}
selectedValue={filters.providerId}
onValueChange={(value) =>
handleFilterChange("providerId", value)
}
label={t("provider")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
<Bot className="h-4 w-4" />
{row.original.providerName || "-"}
</span>
);
}
},
{
accessorKey: "capability",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={Object.entries(capabilityLabels).map(
([value, label]) => ({ value, label })
)}
selectedValue={filters.capability}
onValueChange={(value) =>
handleFilterChange("capability", value)
}
label={t("capability")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="text-xs">
{capabilityLabels[row.original.capability] ||
row.original.capability}
</span>
);
}
},
{
accessorKey: "requestedModel",
header: ({ column }) => (
<span className="px-2">{t("model")}</span>
),
cell: ({ row }) => {
return (
<span className="text-xs text-muted-foreground">
{row.original.requestedModel || "-"}
</span>
);
}
},
{
accessorKey: "resourceName",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.resources.map((res) => ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
}))}
selectedValue={filters.resourceId}
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
label={t("resource")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
if (!row.original.resourceNiceId) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return (
<Link
href={
row.original.resourceType === "site"
? getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
}
onClick={(e) => e.stopPropagation()}
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{
accessorKey: "isStream",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[
{ value: "true", label: t("streaming") },
{ value: "false", label: t("nonStreaming") }
]}
label={t("stream")}
selectedValue={filters.isStream}
onValueChange={(value) =>
handleFilterChange("isStream", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.isStream ? (
<>
<Waves className="h-4 w-4" />
{t("streaming")}
</>
) : (
<span className="text-muted-foreground text-xs">
{t("nonStreaming")}
</span>
)}
</span>
);
}
},
{
accessorKey: "userEmail",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.users.map((user) => ({
value: user.id,
label: user.email || user.id
}))}
selectedValue={filters.actor}
onValueChange={(value) =>
handleFilterChange("actor", value)
}
label={t("actor")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.userEmail ? (
<>
<User className="h-4 w-4" />
{row.original.userEmail}
</>
) : (
<>-</>
)}
</span>
);
}
}
];
const renderExpandedRow = (row: any) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
<div>
<strong>{t("aiSessionId")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.sessionId}
</p>
</div>
<div>
<strong>{t("statusCode")}</strong>
<p className="text-muted-foreground mt-1">
{row.statusCode ?? "N/A"}
</p>
</div>
<div>
<strong>{t("model")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.requestedModel || "N/A"}
</p>
</div>
<div>
<strong>{t("capability")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{capabilityLabels[row.capability] || row.capability}
</p>
</div>
</div>
<AiSessionChatView
normalizedRequest={row.normalizedRequest}
normalizedResponse={row.normalizedResponse}
requestBody={row.requestBody}
responseBody={row.responseBody}
truncated={row.truncated}
/>
</div>
);
};
return (
<>
<SettingsSectionTitle
title={t("aiSessionLogs")}
description={t("aiSessionLogsDescription")}
/>
<LogDataTable
columns={columns}
data={rows}
title={t("aiSessionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="providerName"
onRefresh={() => refetch()}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{
start: dateRange.startDate,
end: dateRange.endDate
}}
defaultSort={{
id: "createdAt",
desc: true
}}
totalCount={totalCount}
currentPage={currentPage}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isLoading}
pageSize={pageSize}
expandable={true}
renderExpandedRow={renderExpandedRow}
/>
</>
);
}
function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
const capabilities = Object.keys(capabilityLabels);
const providers = [
{ id: 1, name: "OpenAI Production" },
{ id: 2, name: "Anthropic Default" },
{ id: 3, name: "Vertex AI" }
];
const resourcesSample = [
{ id: 1, niceId: "resource-1", name: "Resource 1" },
{ id: 2, niceId: "resource-2", name: "Resource 2" }
];
const actors = ["alice@example.com", "bob@example.com", null];
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
const now = Date.now();
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
return Array.from({ length: 10 }, (_, i) => {
const provider = providers[Math.floor(Math.random() * providers.length)];
const resource =
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
const actor = actors[Math.floor(Math.random() * actors.length)];
return {
id: i,
sessionId: `sample-session-${i}`,
orgId: "sample-org",
providerId: provider.id,
providerName: provider.name,
providerType: "openai",
capability:
capabilities[Math.floor(Math.random() * capabilities.length)],
resourceId: resource.id,
siteResourceId: null,
resourceName: resource.name,
resourceNiceId: resource.niceId,
resourceType: "public",
userId: actor ? `user-${i}` : null,
userEmail: actor,
requestedModel: models[Math.floor(Math.random() * models.length)],
isStream: Math.random() > 0.5,
requestBody: null,
responseBody: null,
normalizedRequest: null,
normalizedResponse: null,
truncated: false,
statusCode: 200,
createdAt: Math.floor(
sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs)
)
};
});
}
+11
View File
@@ -3,6 +3,7 @@ import { Env } from "@app/lib/types/env";
import { build } from "@server/build"; import { build } from "@server/build";
import { import {
BellRing, BellRing,
Bot,
Boxes, Boxes,
Building2, Building2,
Cable, Cable,
@@ -227,6 +228,11 @@ export const orgNavSections = (
<SquareMousePointer className="size-4 flex-none" /> <SquareMousePointer className="size-4 flex-none" />
) )
}, },
{
title: "sidebarLogsAi",
href: "/{orgId}/settings/logs/ai",
icon: <Bot className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures ...(!env?.flags.disableEnterpriseFeatures
? [ ? [
{ {
@@ -521,6 +527,11 @@ export const commandBarNavSections = (
href: "/{orgId}/settings/logs/request", href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" /> icon: <SquareMousePointer className="size-4 flex-none" />
}, },
{
title: "commandLogsAi",
href: "/{orgId}/settings/logs/ai",
icon: <Bot className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures ...(!env?.flags.disableEnterpriseFeatures
? [ ? [
{ {
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { AlertTriangle, Bot, Terminal, User as UserIcon, Wrench } from "lucide-react";
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
type AiSessionChatViewProps = {
normalizedRequest: string | null;
normalizedResponse: string | null;
requestBody: string | null;
responseBody: string | null;
truncated: boolean;
};
function parseMessages(json: string | null): NormalizedAiMessage[] | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? (parsed as NormalizedAiMessage[]) : null;
} catch {
return null;
}
}
function prettyRaw(raw: string | null): string | null {
if (!raw) return null;
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
function MessageBubble({ message }: { message: NormalizedAiMessage }) {
const isUser = message.role === "user";
const isSystem = message.role === "system";
const isTool = message.role === "tool";
if (isSystem) {
return (
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<Terminal className="h-3.5 w-3.5 mt-0.5 flex-none" />
<pre className="whitespace-pre-wrap break-words font-sans">
{message.content}
</pre>
</div>
);
}
return (
<div
className={`flex items-start gap-2 ${isUser ? "flex-row-reverse" : ""}`}
>
<div
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full ${
isUser
? "bg-primary text-primary-foreground"
: isTool
? "bg-amber-100 dark:bg-amber-900/40"
: "bg-muted"
}`}
>
{isUser ? (
<UserIcon className="h-4 w-4" />
) : isTool ? (
<Wrench className="h-3.5 w-3.5" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
isUser
? "bg-primary text-primary-foreground"
: isTool
? "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 font-mono text-xs"
: "bg-muted"
}`}
>
{message.content || (
<span className="italic opacity-60">&nbsp;</span>
)}
</div>
</div>
);
}
function RawFallbackBlock({
label,
raw,
noDataLabel,
unparsedLabel
}: {
label: string;
raw: string | null;
noDataLabel: string;
unparsedLabel: string;
}) {
const pretty = prettyRaw(raw);
return (
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-1 text-xs font-medium text-muted-foreground">
{label}
{pretty && (
<span className="ml-2 font-normal italic opacity-70">
{unparsedLabel}
</span>
)}
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{pretty ?? noDataLabel}
</pre>
</div>
);
}
export function AiSessionChatView({
normalizedRequest,
normalizedResponse,
requestBody,
responseBody,
truncated
}: AiSessionChatViewProps) {
const t = useTranslations();
const requestMessages = useMemo(
() => parseMessages(normalizedRequest),
[normalizedRequest]
);
const responseMessages = useMemo(
() => parseMessages(normalizedResponse),
[normalizedResponse]
);
const hasRequestMessages = !!requestMessages && requestMessages.length > 0;
const hasResponseMessages =
!!responseMessages && responseMessages.length > 0;
return (
<div className="space-y-2">
{truncated && (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="h-3.5 w-3.5 flex-none" />
{t("aiSessionLogTruncated")}
</div>
)}
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
{hasRequestMessages ? (
requestMessages!.map((message, i) => (
<MessageBubble key={`req-${i}`} message={message} />
))
) : (
<RawFallbackBlock
label={t("aiSessionRequest")}
raw={requestBody}
noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/>
)}
{hasResponseMessages ? (
responseMessages!.map((message, i) => (
<MessageBubble key={`res-${i}`} message={message} />
))
) : (
<RawFallbackBlock
label={t("aiSessionResponse")}
raw={responseBody}
noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/>
)}
</div>
</div>
);
}
+58
View File
@@ -10,6 +10,7 @@ import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type { import type {
QueryAccessAuditLogResponse, QueryAccessAuditLogResponse,
QueryActionAuditLogResponse, QueryActionAuditLogResponse,
QueryAiSessionLogResponse,
QueryConnectionAuditLogResponse, QueryConnectionAuditLogResponse,
QueryRequestAuditLogResponse QueryRequestAuditLogResponse
} from "@server/routers/auditLogs/types"; } from "@server/routers/auditLogs/types";
@@ -1031,6 +1032,32 @@ export const connectionLogsFiltersSchema = z.object({
export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>; export type ConnectionLogFilters = z.output<typeof connectionLogsFiltersSchema>;
export const aiSessionLogsFiltersSchema = z.object({
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.optional()
.catch(undefined),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.optional()
.catch(undefined),
page: z.coerce.number().optional().catch(0).default(0),
pageSize: z.coerce.number().optional().catch(20).default(20),
providerId: z.string().optional().catch(undefined),
capability: z.string().optional().catch(undefined),
resourceId: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined),
isStream: z.string().optional().catch(undefined)
});
export type AiSessionLogFilters = z.output<typeof aiSessionLogsFiltersSchema>;
export const logQueries = { export const logQueries = {
requestAnalytics: ({ requestAnalytics: ({
orgId, orgId,
@@ -1180,6 +1207,37 @@ export const logQueries = {
} }
return false; return false;
} }
}),
aiSessions: ({
orgId,
filters
}: {
orgId: string;
filters: AiSessionLogFilters;
}) =>
queryOptions({
queryKey: ["AI_SESSION_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const res = await meta!.api.get<
AxiosResponse<QueryAiSessionLogResponse>
>(`/org/${orgId}/logs/ai`, {
params: {
...rest,
limit: pageSize,
offset: page * pageSize
},
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
}) })
}; };