From c1caa30cb9c94d427dd25427c8dfebb1f8aa73f4 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 19 Aug 2026 17:25:32 -0400 Subject: [PATCH] Move session logs to private --- server/lib/billing/tierMatrix.ts | 2 + .../private/lib/alerts/processTestAlerts.ts | 13 + .../private/routers/aiGateway/logAiSession.ts | 288 ++++++++++++++++++ .../routers/billing/featureLifecycle.ts | 13 + server/private/routers/external.ts | 23 ++ server/private/routers/integration.ts | 23 ++ server/routers/aiGateway/logAiSession.ts | 260 +--------------- server/routers/aiGateway/pipeline.ts | 6 +- server/routers/external.ts | 15 - server/routers/integration.ts | 15 - server/routers/org/updateOrg.ts | 36 +++ .../settings/general/security/page.tsx | 220 +++++++------ src/app/[orgId]/settings/logs/ai/page.tsx | 13 +- 13 files changed, 544 insertions(+), 383 deletions(-) create mode 100644 server/private/routers/aiGateway/logAiSession.ts diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 7e49121dc..c04a60aed 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -9,6 +9,7 @@ export enum TierFeature { AccessLogs = "accessLogs", // set the retention period to none on downgrade ActionLogs = "actionLogs", // set the retention period to none on downgrade ConnectionLogs = "connectionLogs", + AISessionLogs = "aiSessionLogs", RotateCredentials = "rotateCredentials", MaintenancePage = "maintenancePage", // handle downgrade DevicePosture = "devicePosture", @@ -37,6 +38,7 @@ export const tierMatrix: Record = { [TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"], + [TierFeature.AISessionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"], diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index f7fa47b20..5b2b8c878 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -1,3 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { db, userOrgRoles, users } from "@server/db"; import logger from "@server/logger"; import type { diff --git a/server/private/routers/aiGateway/logAiSession.ts b/server/private/routers/aiGateway/logAiSession.ts new file mode 100644 index 000000000..527dc0b5b --- /dev/null +++ b/server/private/routers/aiGateway/logAiSession.ts @@ -0,0 +1,288 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db"; +import type { InferInsertModel } from "drizzle-orm"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "#dynamic/lib/cache"; +import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { sanitizeString } from "@server/lib/sanitize"; +import type { AiCapability } from "@server/lib/aiCapabilities"; +import { + normalizeAiRequest, + normalizeAiResponse +} from "@server/lib/aiMessageNormalization"; + +// Caps how much of the request/response body we keep per row, so a single +// huge multimodal payload can't blow up buffer memory or storage. +const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000; + +type AiSessionLogInsert = InferInsertModel; + +// In-memory buffer for batching AI session log inserts, mirroring the +// approach in server/routers/badger/logRequestAudit.ts. +const sessionLogBuffer: AiSessionLogInsert[] = []; + +const BATCH_SIZE = 100; // Write to DB every 100 logs +const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first +const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth +let flushTimer: NodeJS.Timeout | null = null; +let isFlushInProgress = false; + +/** + * Flush buffered logs to database + */ +async function flushSessionLogs() { + if (sessionLogBuffer.length === 0 || isFlushInProgress) { + return; + } + + isFlushInProgress = true; + + // Take all current logs and clear buffer + const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length); + + try { + // Use a transaction to ensure all inserts succeed or fail together + await logsDb.transaction(async (tx) => { + // Batch insert logs in groups of 25 to avoid overwhelming the database + const BATCH_DB_SIZE = 25; + for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { + const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); + await tx.insert(aiSessionLog).values(batch); + } + }); + logger.debug( + `Flushed ${logsToWrite.length} AI session logs to database` + ); + } catch (error) { + logger.error("Error flushing AI session logs:", error); + // On transaction error, put logs back at the front of the buffer to retry + // but only if buffer isn't too large + if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { + sessionLogBuffer.unshift(...logsToWrite); + logger.info( + `Re-queued ${logsToWrite.length} AI session logs for retry` + ); + } else { + logger.error( + `Buffer full, dropped ${logsToWrite.length} AI session logs` + ); + } + } finally { + isFlushInProgress = false; + // If buffer filled up while we were flushing, flush again + if (sessionLogBuffer.length >= BATCH_SIZE) { + flushSessionLogs().catch((err) => + logger.error("Error in follow-up AI session log flush:", err) + ); + } + } +} + +/** + * Schedule a flush if not already scheduled + */ +function scheduleFlush() { + if (flushTimer === null) { + flushTimer = setTimeout(() => { + flushTimer = null; + flushSessionLogs().catch((err) => + logger.error("Error in scheduled AI session log flush:", err) + ); + }, BATCH_INTERVAL_MS); + } +} + +/** + * Gracefully flush all pending logs (call this on shutdown) + */ +export async function shutdownAiSessionLogger() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + // Force flush even if one is in progress by waiting and retrying + while (isFlushInProgress) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await flushSessionLogs(); +} + +async function getRetentionDays(orgId: string): Promise { + // check cache first + const cached = await cache.get(`org_${orgId}_aiSessionsDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAISessions: + orgs.settingsLogRetentionDaysAISessions + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + await cache.set( + `org_${orgId}_aiSessionsDays`, + org.settingsLogRetentionDaysAISessions, + 300 + ); + + return org.settingsLogRetentionDaysAISessions; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + // calculateCutoffTimestamp returns a seconds-epoch cutoff (built for + // requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to + // match aiUsageRecords - convert before comparing. + const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000; + + try { + await logsDb + .delete(aiSessionLog) + .where( + and( + lt(aiSessionLog.createdAt, cutoffTimestampMs), + eq(aiSessionLog.orgId, orgId) + ) + ); + } catch (error) { + logger.error("Error cleaning up old AI session logs:", error); + } +} + +function truncateBody(value: string): { value: string; truncated: boolean } { + if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) { + return { value, truncated: false }; + } + return { + value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS), + truncated: true + }; +} + +export function logAiSession(data: { + sessionId: string; + capability: AiCapability; + provider: AiProvider; + requestedModel: string | undefined; + requestBody: unknown; + responseText: string; + isStream: boolean; + statusCode: number; + orgId: string | null; + resourceId: number | null; + siteResourceId: number | null; + requestUserId: string | null; + virtualApiKeyId: string | null; +}): void { + (async () => { + try { + // Check retention before buffering any logs + if (data.orgId) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays === 0) { + // do not log + return; + } + } else { + // No org resolved for this request - nothing to govern + // retention with, so don't log it. + return; + } + + const requestBodyText = truncateBody( + JSON.stringify(data.requestBody ?? "") + ); + const responseBodyText = truncateBody(data.responseText ?? ""); + + // Uniform, capability-agnostic transcript for search/display - + // computed from the untruncated originals so normalization sees + // the full content; the normalized result gets its own + // (typically much smaller) truncation pass below. + const normalizedRequestMessages = normalizeAiRequest( + data.capability, + data.requestBody + ); + const normalizedResponseMessages = normalizeAiResponse( + data.capability, + data.responseText ?? "", + data.isStream + ); + const normalizedRequestText = normalizedRequestMessages + ? truncateBody(JSON.stringify(normalizedRequestMessages)) + : null; + const normalizedResponseText = normalizedResponseMessages + ? truncateBody(JSON.stringify(normalizedResponseMessages)) + : null; + + // Prevent unbounded buffer growth - drop oldest entries if buffer is too large + if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) { + const dropped = sessionLogBuffer.splice(0, BATCH_SIZE); + logger.warn( + `AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries` + ); + } + + sessionLogBuffer.push({ + sessionId: data.sessionId, + orgId: sanitizeString(data.orgId), + providerId: data.provider.providerId, + capability: data.capability, + resourceId: data.resourceId ?? undefined, + siteResourceId: data.siteResourceId ?? undefined, + userId: sanitizeString(data.requestUserId ?? undefined), + virtualApiKeyId: sanitizeString( + data.virtualApiKeyId ?? undefined + ), + requestedModel: sanitizeString(data.requestedModel), + isStream: data.isStream, + requestBody: sanitizeString(requestBodyText.value), + responseBody: sanitizeString(responseBodyText.value), + normalizedRequest: normalizedRequestText + ? sanitizeString(normalizedRequestText.value) + : undefined, + normalizedResponse: normalizedResponseText + ? sanitizeString(normalizedResponseText.value) + : undefined, + truncated: + requestBodyText.truncated || + responseBodyText.truncated || + (normalizedRequestText?.truncated ?? false) || + (normalizedResponseText?.truncated ?? false), + statusCode: data.statusCode, + createdAt: Date.now() + }); + + // Flush immediately if buffer is full, otherwise schedule a flush + if (sessionLogBuffer.length >= BATCH_SIZE) { + flushSessionLogs().catch((err) => + logger.error("Error flushing AI session logs:", err) + ); + } else { + scheduleFlush(); + } + } catch (error) { + logger.error("Failed to log AI session", { error }); + } + })(); +} diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index b32d83f7e..b7487fe49 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -291,6 +291,10 @@ async function disableFeature( await disableConnectionLogs(orgId); break; + case TierFeature.AISessionLogs: + await disableAISessionLogs(orgId); + break; + case TierFeature.RotateCredentials: await disableRotateCredentials(orgId); break; @@ -493,6 +497,15 @@ async function disableConnectionLogs(orgId: string): Promise { logger.info(`Disabled connection logs for org ${orgId}`); } +async function disableAISessionLogs(orgId: string): Promise { + await db + .update(orgs) + .set({ settingsLogRetentionDaysAISessions: 0 }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled AI session logs for org ${orgId}`); +} + async function disableRotateCredentials(orgId: string): Promise {} async function disablemaintenancePage(orgId: string): Promise { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 5cc2b1545..0110bf51d 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -21,6 +21,10 @@ import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; import * as generateLicense from "#private/routers/generatedLicense"; import * as logs from "#private/routers/auditLogs"; +import { + queryAiSessionLogs, + exportAiSessionLogs +} from "@server/routers/auditLogs"; import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; @@ -591,6 +595,25 @@ authenticated.get( logs.exportConnectionAuditLogs ); +authenticated.get( + "/org/:orgId/logs/ai", + verifyValidLicense, + verifyValidSubscription(tierMatrix.aiSessionLogs), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + queryAiSessionLogs +); + +authenticated.get( + "/org/:orgId/logs/ai/export", + verifyValidLicense, + verifyValidSubscription(tierMatrix.aiSessionLogs), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + exportAiSessionLogs +); + authenticated.post( "/re-key/:clientId/regenerate-client-secret", verifyClientAccess, // this is first to set the org id diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 8a1e15c2f..814fa8e4a 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -43,6 +43,10 @@ import { unauthenticated as ua, authenticated as a } from "@server/routers/integration"; +import { + queryAiSessionLogs, + exportAiSessionLogs +} from "@server/routers/auditLogs"; import { logActionAudit } from "#private/middlewares"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; @@ -153,6 +157,25 @@ authenticated.get( logs.exportConnectionAuditLogs ); +authenticated.get( + "/org/:orgId/logs/ai", + verifyValidLicense, + verifyValidSubscription(tierMatrix.aiSessionLogs), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.viewLogs), + queryAiSessionLogs +); + +authenticated.get( + "/org/:orgId/logs/ai/export", + verifyValidLicense, + verifyValidSubscription(tierMatrix.aiSessionLogs), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + exportAiSessionLogs +); + authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, diff --git a/server/routers/aiGateway/logAiSession.ts b/server/routers/aiGateway/logAiSession.ts index 5a2e29bdb..756d53d97 100644 --- a/server/routers/aiGateway/logAiSession.ts +++ b/server/routers/aiGateway/logAiSession.ts @@ -1,171 +1,12 @@ -import { logsDb, db, orgs, aiSessionLog, type AiProvider } from "@server/db"; -import type { InferInsertModel } from "drizzle-orm"; -import logger from "@server/logger"; -import { and, eq, lt } from "drizzle-orm"; -import cache from "#dynamic/lib/cache"; -import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; -import { sanitizeString } from "@server/lib/sanitize"; -import type { AiCapability } from "@server/lib/aiCapabilities"; -import { - normalizeAiRequest, - normalizeAiResponse -} from "@server/lib/aiMessageNormalization"; - -// Caps how much of the request/response body we keep per row, so a single -// huge multimodal payload can't blow up buffer memory or storage. -const AI_SESSION_LOG_MAX_BODY_CHARS = 200_000; - -type AiSessionLogInsert = InferInsertModel; - -// In-memory buffer for batching AI session log inserts, mirroring the -// approach in server/routers/badger/logRequestAudit.ts. -const sessionLogBuffer: AiSessionLogInsert[] = []; - -const BATCH_SIZE = 100; // Write to DB every 100 logs -const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first -const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth -let flushTimer: NodeJS.Timeout | null = null; -let isFlushInProgress = false; - -/** - * Flush buffered logs to database - */ -async function flushSessionLogs() { - if (sessionLogBuffer.length === 0 || isFlushInProgress) { - return; - } - - isFlushInProgress = true; - - // Take all current logs and clear buffer - const logsToWrite = sessionLogBuffer.splice(0, sessionLogBuffer.length); - - try { - // Use a transaction to ensure all inserts succeed or fail together - await logsDb.transaction(async (tx) => { - // Batch insert logs in groups of 25 to avoid overwhelming the database - const BATCH_DB_SIZE = 25; - for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { - const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); - await tx.insert(aiSessionLog).values(batch); - } - }); - logger.debug( - `Flushed ${logsToWrite.length} AI session logs to database` - ); - } catch (error) { - logger.error("Error flushing AI session logs:", error); - // On transaction error, put logs back at the front of the buffer to retry - // but only if buffer isn't too large - if (sessionLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { - sessionLogBuffer.unshift(...logsToWrite); - logger.info( - `Re-queued ${logsToWrite.length} AI session logs for retry` - ); - } else { - logger.error( - `Buffer full, dropped ${logsToWrite.length} AI session logs` - ); - } - } finally { - isFlushInProgress = false; - // If buffer filled up while we were flushing, flush again - if (sessionLogBuffer.length >= BATCH_SIZE) { - flushSessionLogs().catch((err) => - logger.error("Error in follow-up AI session log flush:", err) - ); - } - } -} - -/** - * Schedule a flush if not already scheduled - */ -function scheduleFlush() { - if (flushTimer === null) { - flushTimer = setTimeout(() => { - flushTimer = null; - flushSessionLogs().catch((err) => - logger.error("Error in scheduled AI session log flush:", err) - ); - }, BATCH_INTERVAL_MS); - } -} +import { AiCapability } from "@app/lib/aiCapabilities"; +import { AiProvider } from "@server/db"; /** * Gracefully flush all pending logs (call this on shutdown) */ -export async function shutdownAiSessionLogger() { - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } - // Force flush even if one is in progress by waiting and retrying - while (isFlushInProgress) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - await flushSessionLogs(); -} +export async function shutdownAiSessionLogger() {} -async function getRetentionDays(orgId: string): Promise { - // check cache first - const cached = await cache.get(`org_${orgId}_aiSessionsDays`); - if (cached !== undefined) { - return cached; - } - - const [org] = await db - .select({ - settingsLogRetentionDaysAISessions: - orgs.settingsLogRetentionDaysAISessions - }) - .from(orgs) - .where(eq(orgs.orgId, orgId)) - .limit(1); - - if (!org) { - return 0; - } - - // store the result in cache - await cache.set( - `org_${orgId}_aiSessionsDays`, - org.settingsLogRetentionDaysAISessions, - 300 - ); - - return org.settingsLogRetentionDaysAISessions; -} - -export async function cleanUpOldLogs(orgId: string, retentionDays: number) { - // calculateCutoffTimestamp returns a seconds-epoch cutoff (built for - // requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to - // match aiUsageRecords - convert before comparing. - const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000; - - try { - await logsDb - .delete(aiSessionLog) - .where( - and( - lt(aiSessionLog.createdAt, cutoffTimestampMs), - eq(aiSessionLog.orgId, orgId) - ) - ); - } catch (error) { - logger.error("Error cleaning up old AI session logs:", error); - } -} - -function truncateBody(value: string): { value: string; truncated: boolean } { - if (value.length <= AI_SESSION_LOG_MAX_BODY_CHARS) { - return { value, truncated: false }; - } - return { - value: value.slice(0, AI_SESSION_LOG_MAX_BODY_CHARS), - truncated: true - }; -} +export async function cleanUpOldLogs(orgId: string, retentionDays: number) {} export function logAiSession(data: { sessionId: string; @@ -181,95 +22,4 @@ export function logAiSession(data: { siteResourceId: number | null; requestUserId: string | null; virtualApiKeyId: string | null; -}): void { - (async () => { - try { - // Check retention before buffering any logs - if (data.orgId) { - const retentionDays = await getRetentionDays(data.orgId); - if (retentionDays === 0) { - // do not log - return; - } - } else { - // No org resolved for this request - nothing to govern - // retention with, so don't log it. - return; - } - - const requestBodyText = truncateBody( - JSON.stringify(data.requestBody ?? "") - ); - const responseBodyText = truncateBody(data.responseText ?? ""); - - // Uniform, capability-agnostic transcript for search/display - - // computed from the untruncated originals so normalization sees - // the full content; the normalized result gets its own - // (typically much smaller) truncation pass below. - const normalizedRequestMessages = normalizeAiRequest( - data.capability, - data.requestBody - ); - const normalizedResponseMessages = normalizeAiResponse( - data.capability, - data.responseText ?? "", - data.isStream - ); - const normalizedRequestText = normalizedRequestMessages - ? truncateBody(JSON.stringify(normalizedRequestMessages)) - : null; - const normalizedResponseText = normalizedResponseMessages - ? truncateBody(JSON.stringify(normalizedResponseMessages)) - : null; - - // Prevent unbounded buffer growth - drop oldest entries if buffer is too large - if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) { - const dropped = sessionLogBuffer.splice(0, BATCH_SIZE); - logger.warn( - `AI session log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries` - ); - } - - sessionLogBuffer.push({ - sessionId: data.sessionId, - orgId: sanitizeString(data.orgId), - providerId: data.provider.providerId, - capability: data.capability, - resourceId: data.resourceId ?? undefined, - siteResourceId: data.siteResourceId ?? undefined, - userId: sanitizeString(data.requestUserId ?? undefined), - virtualApiKeyId: sanitizeString( - data.virtualApiKeyId ?? undefined - ), - requestedModel: sanitizeString(data.requestedModel), - isStream: data.isStream, - requestBody: sanitizeString(requestBodyText.value), - responseBody: sanitizeString(responseBodyText.value), - normalizedRequest: normalizedRequestText - ? sanitizeString(normalizedRequestText.value) - : undefined, - normalizedResponse: normalizedResponseText - ? sanitizeString(normalizedResponseText.value) - : undefined, - truncated: - requestBodyText.truncated || - responseBodyText.truncated || - (normalizedRequestText?.truncated ?? false) || - (normalizedResponseText?.truncated ?? false), - statusCode: data.statusCode, - createdAt: Date.now() - }); - - // Flush immediately if buffer is full, otherwise schedule a flush - if (sessionLogBuffer.length >= BATCH_SIZE) { - flushSessionLogs().catch((err) => - logger.error("Error flushing AI session logs:", err) - ); - } else { - scheduleFlush(); - } - } catch (error) { - logger.error("Failed to log AI session", { error }); - } - })(); -} +}): void {} diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index e11e14a62..76889cdc8 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -86,7 +86,7 @@ import { type AiUsage } from "@server/lib/aiUsageExtraction"; import { streamAiGatewayResponse } from "@server/routers/aiGateway/streamAiGatewayResponse"; -import { logAiSession } from "@server/routers/aiGateway/logAiSession"; +import { logAiSession } from "#dynamic/routers/aiGateway/logAiSession"; const EXIT_NODE_RANGES_CACHE_KEY = "aiGateway:exitNodeRanges"; const EXIT_NODE_RANGES_TTL_SEC = 6000; @@ -728,7 +728,9 @@ export function recordAiGatewayCompletion(args: { let cost: ReturnType = null; if (upstreamSucceeded) { - usage = extractUsage(capability, responseText, isStream, headers) ?? emptyUsage(); + usage = + extractUsage(capability, responseText, isStream, headers) ?? + emptyUsage(); if (isUsageEmpty(usage)) { usage = estimateUsage( JSON.stringify(requestBody ?? ""), diff --git a/server/routers/external.ts b/server/routers/external.ts index 2d91868bc..f4442bd08 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1490,21 +1490,6 @@ 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/logs/ai/usage/filters", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 214c1ff27..bcf5a6a32 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1532,21 +1532,6 @@ 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/ai/usage/filters", verifyApiKeyOrgAccess, diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 021448375..3c35cecc1 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -147,6 +147,42 @@ export async function updateOrg( parsedBody.data.settingsEnableGlobalNewtAutoUpdate = false; // force it off } + // Check access logs feature + const hasAccessLogsFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.AccessLogs] + ); + if (!hasAccessLogsFeature) { + parsedBody.data.settingsLogRetentionDaysAccess = undefined; + } + + // Check action logs feature + const hasActionLogsFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.ActionLogs] + ); + if (!hasActionLogsFeature) { + parsedBody.data.settingsLogRetentionDaysAction = undefined; + } + + // Check connection logs feature + const hasConnectionLogsFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.ConnectionLogs] + ); + if (!hasConnectionLogsFeature) { + parsedBody.data.settingsLogRetentionDaysConnection = undefined; + } + + // Check AI session logs feature + const hasAISessionLogsFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.AISessionLogs] + ); + if (!hasAISessionLogsFeature) { + parsedBody.data.settingsLogRetentionDaysAISessions = undefined; + } + if (build == "saas") { const { tier } = await getOrgTierData(orgId); diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx index 7f83711b5..2fd6405a7 100644 --- a/src/app/[orgId]/settings/general/security/page.tsx +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -298,101 +298,6 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { )} /> - ( - - - {t("logRetentionAISessionsLabel")} - - - - - - - )} - /> - {!env.flags.disableEnterpriseFeatures && ( <> + { + const isDisabled = !isPaidUser( + tierMatrix.aiSessionLogs + ); + + return ( + + + {t( + "logRetentionAISessionsLabel" + )} + + + + + + + ); + }} + /> )} diff --git a/src/app/[orgId]/settings/logs/ai/page.tsx b/src/app/[orgId]/settings/logs/ai/page.tsx index 4916d21be..136d186c3 100644 --- a/src/app/[orgId]/settings/logs/ai/page.tsx +++ b/src/app/[orgId]/settings/logs/ai/page.tsx @@ -3,11 +3,13 @@ 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 { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import LogRetentionWarning from "@app/components/LogRetentionWarning"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useTranslations } from "next-intl"; @@ -15,6 +17,8 @@ import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref"; import { logQueries } from "@app/lib/queries"; import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { ColumnDef } from "@tanstack/react-table"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; @@ -44,6 +48,7 @@ export default function AiSessionLogsPage() { const searchParams = useSearchParams(); const { org } = useOrgContext(); + const { isPaidUser } = usePaidStatus(); const [isExporting, startTransition] = useTransition(); @@ -133,7 +138,8 @@ export default function AiSessionLogsPage() { ...logQueries.aiSessions({ orgId: orgId as string, filters: queryFilters - }) + }), + enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss" }); const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []); @@ -645,6 +651,8 @@ export default function AiSessionLogsPage() { description={t("aiSessionLogsDescription")} /> + + {org.org.settingsLogRetentionDaysAISessions === 0 && ( );