import { and, count, countDistinct, gte, lt, sql } from "drizzle-orm"; import { db } from "../db/db.controller.js"; import { analytics } from "../db/schema/analytics.schema.js"; import { analyticsDaily } from "../db/schema/dailyAnalytics.schema.js"; export const ignoredRoutePrefixes = [ "/health", "/favicon.ico", "/socket.io", "/lst/api/ws", "/lst-config.js", ]; export function shouldIgnoreRoute(path: string) { return ignoredRoutePrefixes.some((prefix) => path.startsWith(prefix)); } type CreateRouteHitInput = { method: string; routePattern: string; actualPath: string; statusCode: number; durationMs: number; module?: string | null; userId?: string | null; userEmail?: string | null; ipAddress?: string | null; userAgent?: string | null; }; export async function createRouteHit(input: CreateRouteHitInput) { await db.insert(analytics).values(input); } function getPreviousBusinessDayWindow(date = new Date()) { const end = new Date(date); end.setHours(7, 0, 0, 0); const start = new Date(end); start.setDate(start.getDate() - 1); const businessDate = start.toISOString().slice(0, 10); return { start, end, businessDate, }; } export async function runRouteHitAnalyticsCron(): Promise { const result = await aggregateRouteHitsForBusinessDay(); await cleanupOldRouteHits(); console.log("Route hit analytics aggregated", result); } export async function aggregateRouteHitsForBusinessDay() { const { start, end, businessDate } = getPreviousBusinessDayWindow(); const rows = await db .select({ businessDate: sql`CAST(${businessDate} AS date)`, method: analytics.method, routePattern: analytics.routePattern, module: sql`COALESCE(${analytics.module}, 'unknown')`, totalHits: count(), uniqueUsers: countDistinct(analytics.userId), successCount: sql` COUNT(*) FILTER (WHERE ${analytics.statusCode} < 400) `, errorCount: sql` COUNT(*) FILTER (WHERE ${analytics.statusCode} >= 400) `, avgDurationMs: sql` COALESCE(ROUND(AVG(${analytics.durationMs})), 0) `, maxDurationMs: sql` COALESCE(MAX(${analytics.durationMs}), 0) `, firstHitAt: sql` COALESCE(MIN(${analytics.createdAt}), NOW()) `, lastHitAt: sql` COALESCE(MAX(${analytics.createdAt}), NOW()) `, }) .from(analytics) .where(and(gte(analytics.createdAt, start), lt(analytics.createdAt, end))) .groupBy( analytics.method, analytics.routePattern, sql`COALESCE(${analytics.module}, 'unknown')`, ); if (rows.length === 0) { return { businessDate, inserted: 0, }; } const values = rows.map((row) => ({ ...row, businessDate: row.businessDate, firstHitAt: new Date(row.firstHitAt), lastHitAt: new Date(row.lastHitAt), })); await db .insert(analyticsDaily) .values(values) .onConflictDoUpdate({ target: [ analyticsDaily.businessDate, analyticsDaily.method, analyticsDaily.routePattern, analyticsDaily.module, ], set: { totalHits: sql`excluded.total_hits`, uniqueUsers: sql`excluded.unique_users`, successCount: sql`excluded.success_count`, errorCount: sql`excluded.error_count`, avgDurationMs: sql`excluded.avg_duration_ms`, maxDurationMs: sql`excluded.max_duration_ms`, firstHitAt: sql`excluded.first_hit_at`, lastHitAt: sql`excluded.last_hit_at`, updatedAt: sql`now()`, }, }); return { businessDate, inserted: rows.length, }; } export async function cleanupOldRouteHits() { await db .delete(analytics) .where(lt(analytics.createdAt, sql`now() - interval '4 days'`)); }