diff --git a/messages/en-US.json b/messages/en-US.json index 0eaf60378..0784fdf2e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3388,6 +3388,23 @@ "logRetention": "Log Retention", "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", + "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", "logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", diff --git a/server/routers/auditLogs/exportAiSessionLog.ts b/server/routers/auditLogs/exportAiSessionLog.ts new file mode 100644 index 000000000..2e673c107 --- /dev/null +++ b/server/routers/auditLogs/exportAiSessionLog.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 9bea762f7..51d970360 100644 --- a/server/routers/auditLogs/index.ts +++ b/server/routers/auditLogs/index.ts @@ -1,3 +1,5 @@ export * from "./queryRequestAuditLog"; export * from "./queryRequestAnalytics"; export * from "./exportRequestAuditLog"; +export * from "./queryAiSessionLog"; +export * from "./exportAiSessionLog"; diff --git a/server/routers/auditLogs/queryAiSessionLog.ts b/server/routers/auditLogs/queryAiSessionLog.ts new file mode 100644 index 000000000..913eacf65 --- /dev/null +++ b/server/routers/auditLogs/queryAiSessionLog.ts @@ -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; + +function sortNamedFilterOptions( + 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> +) { + 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(); + 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 { + 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(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") + ); + } +} diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 15ca1e87e..cf85e5a2f 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -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 = { log: { sessionId: string; diff --git a/server/routers/external.ts b/server/routers/external.ts index 9c2f38e3d..dd1167d55 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1487,6 +1487,21 @@ authenticated.get( 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( "/org/:orgId/blueprints", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 4489fa778..87ec88243 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1530,6 +1530,21 @@ authenticated.get( 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( "/org/:orgId/logs/analytics", verifyApiKeyOrgAccess, diff --git a/src/app/[orgId]/settings/logs/ai/layout.tsx b/src/app/[orgId]/settings/logs/ai/layout.tsx new file mode 100644 index 000000000..5e651cb4f --- /dev/null +++ b/src/app/[orgId]/settings/logs/ai/layout.tsx @@ -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; +} diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx new file mode 100644 index 000000000..2c4c2ed32 --- /dev/null +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -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 = { + 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(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[] = [ + { + accessorKey: "createdAt", + header: ({ column }) => ( + {t("timestamp")} + ), + cell: ({ row }) => { + return ( +
+ {new Date(row.original.createdAt).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "providerName", + header: ({ column }) => { + return ( +
+ ({ + 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")} + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + + {row.original.providerName || "-"} + + ); + } + }, + { + accessorKey: "capability", + header: ({ column }) => { + return ( +
+ ({ value, label }) + )} + selectedValue={filters.capability} + onValueChange={(value) => + handleFilterChange("capability", value) + } + label={t("capability")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {capabilityLabels[row.original.capability] || + row.original.capability} + + ); + } + }, + { + accessorKey: "requestedModel", + header: ({ column }) => ( + {t("model")} + ), + cell: ({ row }) => { + return ( + + {row.original.requestedModel || "-"} + + ); + } + }, + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( +
+ ({ + 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")} + /> +
+ ); + }, + cell: ({ row }) => { + if (!row.original.resourceNiceId) { + return -; + } + return ( + e.stopPropagation()} + > + + + ); + } + }, + { + accessorKey: "isStream", + header: ({ column }) => { + return ( +
+ + handleFilterChange("isStream", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.isStream ? ( + <> + + {t("streaming")} + + ) : ( + + {t("nonStreaming")} + + )} + + ); + } + }, + { + accessorKey: "userEmail", + header: ({ column }) => { + return ( +
+ ({ + 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")} + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.userEmail ? ( + <> + + {row.original.userEmail} + + ) : ( + <>- + )} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ {t("aiSessionId")} +

+ {row.sessionId} +

+
+
+ {t("statusCode")} +

+ {row.statusCode ?? "N/A"} +

+
+
+ {t("model")} +

+ {row.requestedModel || "N/A"} +

+
+
+ {t("capability")} +

+ {capabilityLabels[row.capability] || row.capability} +

+
+
+ +
+ ); + }; + + return ( + <> + + + 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) + ) + }; + }); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index a9ffcca55..7181fd1c6 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -3,6 +3,7 @@ import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { BellRing, + Bot, Boxes, Building2, Cable, @@ -227,6 +228,11 @@ export const orgNavSections = ( ) }, + { + title: "sidebarLogsAi", + href: "/{orgId}/settings/logs/ai", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { @@ -521,6 +527,11 @@ export const commandBarNavSections = ( href: "/{orgId}/settings/logs/request", icon: }, + { + title: "commandLogsAi", + href: "/{orgId}/settings/logs/ai", + icon: + }, ...(!env?.flags.disableEnterpriseFeatures ? [ { diff --git a/src/components/AiSessionChatView.tsx b/src/components/AiSessionChatView.tsx new file mode 100644 index 000000000..0a279b27e --- /dev/null +++ b/src/components/AiSessionChatView.tsx @@ -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 ( +
+ +
+                    {message.content}
+                
+
+ ); + } + + return ( +
+
+ {isUser ? ( + + ) : isTool ? ( + + ) : ( + + )} +
+
+ {message.content || ( +   + )} +
+
+ ); +} + +function RawFallbackBlock({ + label, + raw, + noDataLabel, + unparsedLabel +}: { + label: string; + raw: string | null; + noDataLabel: string; + unparsedLabel: string; +}) { + const pretty = prettyRaw(raw); + return ( +
+
+ {label} + {pretty && ( + + {unparsedLabel} + + )} +
+
+                {pretty ?? noDataLabel}
+            
+
+ ); +} + +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 ( +
+ {truncated && ( +
+ + {t("aiSessionLogTruncated")} +
+ )} +
+ {hasRequestMessages ? ( + requestMessages!.map((message, i) => ( + + )) + ) : ( + + )} + {hasResponseMessages ? ( + responseMessages!.map((message, i) => ( + + )) + ) : ( + + )} +
+
+ ); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 02a9404e8..3a4c024d4 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -10,6 +10,7 @@ import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { QueryAccessAuditLogResponse, QueryActionAuditLogResponse, + QueryAiSessionLogResponse, QueryConnectionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; @@ -1031,6 +1032,32 @@ export const connectionLogsFiltersSchema = z.object({ export type ConnectionLogFilters = z.output; +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; + export const logQueries = { requestAnalytics: ({ orgId, @@ -1180,6 +1207,37 @@ export const logQueries = { } 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 + >(`/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; + } }) };