Session logs and usage logs should use seconds not ms

This commit is contained in:
Owen
2026-08-26 10:50:04 -04:00
parent 3d4e143c1f
commit e57826d6e0
7 changed files with 20 additions and 19 deletions
+1 -1
View File
@@ -1984,7 +1984,7 @@ export const aiSessionLog = pgTable(
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage. // were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false), truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"), statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
}, },
(t) => [ (t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt), index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
+1 -1
View File
@@ -1980,7 +1980,7 @@ export const aiSessionLog = sqliteTable(
.notNull() .notNull()
.default(false), .default(false),
statusCode: integer("statusCode"), statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch ms createdAt: integer("createdAt").notNull() // epoch seconds
}, },
(t) => [ (t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt), index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
+3 -1
View File
@@ -580,6 +580,8 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
); );
} }
const timestamp = Math.floor(Date.now() / 1000);
usageRecordBuffer.push({ usageRecordBuffer.push({
orgId: input.orgId, orgId: input.orgId,
providerId: input.providerId, providerId: input.providerId,
@@ -597,7 +599,7 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
totalTokens, totalTokens,
costUsd: input.costUsd, costUsd: input.costUsd,
estimated: usage.estimated, estimated: usage.estimated,
createdAt: input.createdAt ?? Date.now() createdAt: input.createdAt ?? timestamp
}); });
// Flush immediately if buffer is full, otherwise schedule a flush // Flush immediately if buffer is full, otherwise schedule a flush
@@ -151,17 +151,14 @@ async function getRetentionDays(orgId: string): Promise<number> {
} }
export async function cleanUpOldLogs(orgId: string, retentionDays: number) { export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for const cutoffTimestamp = calculateCutoffTimestamp(retentionDays) * 1000;
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
// match aiUsageRecords - convert before comparing.
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
try { try {
await logsDb await logsDb
.delete(aiSessionLog) .delete(aiSessionLog)
.where( .where(
and( and(
lt(aiSessionLog.createdAt, cutoffTimestampMs), lt(aiSessionLog.createdAt, cutoffTimestamp),
eq(aiSessionLog.orgId, orgId) eq(aiSessionLog.orgId, orgId)
) )
); );
@@ -243,6 +240,8 @@ export function logAiSession(data: {
); );
} }
const timestamp = Math.floor(Date.now() / 1000);
sessionLogBuffer.push({ sessionLogBuffer.push({
sessionId: data.sessionId, sessionId: data.sessionId,
orgId: sanitizeString(data.orgId), orgId: sanitizeString(data.orgId),
@@ -270,7 +269,7 @@ export function logAiSession(data: {
(normalizedRequestText?.truncated ?? false) || (normalizedRequestText?.truncated ?? false) ||
(normalizedResponseText?.truncated ?? false), (normalizedResponseText?.truncated ?? false),
statusCode: data.statusCode, statusCode: data.statusCode,
createdAt: Date.now() createdAt: timestamp
}); });
// Flush immediately if buffer is full, otherwise schedule a flush // Flush immediately if buffer is full, otherwise schedule a flush
@@ -18,7 +18,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => new Date(val).getTime()) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString()) .prefault(() => getSevenDaysAgo().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -31,7 +31,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => new Date(val).getTime()) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -122,12 +122,12 @@ export function buildAiUsageWhere(
); );
} }
// Buckets createdAt (epoch ms) down to a per-day string, dialect-aware, same // Buckets createdAt (epoch seconds) down to a per-day string, dialect-aware,
// approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts. // same approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts.
export function dayBucketExpr() { export function dayBucketExpr() {
return driver === "pg" return driver === "pg"
? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))` ? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt}))`
: sql<string>`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`; : sql<string>`DATE(${aiUsageRecords.createdAt}, 'unixepoch')`;
} }
export type DailyMetricRow<K extends string> = { export type DailyMetricRow<K extends string> = {
@@ -32,7 +32,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => new Date(val).getTime()) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString()) .prefault(() => getSevenDaysAgo().toISOString())
.openapi({ .openapi({
type: "string", type: "string",
@@ -45,7 +45,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => new Date(val).getTime()) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional() .optional()
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
.openapi({ .openapi({
@@ -30,14 +30,14 @@ const queryAiUsageFilterOptionsQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => new Date(val).getTime()) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString()), .prefault(() => getSevenDaysAgo().toISOString()),
timeEnd: z timeEnd: z
.string() .string()
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.transform((val) => new Date(val).getTime()) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => new Date().toISOString()) .prefault(() => new Date().toISOString())
}); });