142 lines
3.4 KiB
TypeScript
142 lines
3.4 KiB
TypeScript
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<void> {
|
|
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<string>`${businessDate}`,
|
|
method: analytics.method,
|
|
routePattern: analytics.routePattern,
|
|
module: sql<string>`COALESCE(${analytics.module}, 'unknown')`,
|
|
|
|
totalHits: count(),
|
|
uniqueUsers: countDistinct(analytics.userId),
|
|
|
|
successCount: sql<number>`
|
|
COUNT(*) FILTER (WHERE ${analytics.statusCode} < 400)
|
|
`,
|
|
errorCount: sql<number>`
|
|
COUNT(*) FILTER (WHERE ${analytics.statusCode} >= 400)
|
|
`,
|
|
|
|
avgDurationMs: sql<number>`
|
|
COALESCE(ROUND(AVG(${analytics.durationMs})), 0)
|
|
`,
|
|
maxDurationMs: sql<number>`
|
|
COALESCE(MAX(${analytics.durationMs}), 0)
|
|
`,
|
|
|
|
firstHitAt: sql<Date>`
|
|
COALESCE(MIN(${analytics.createdAt}), NOW())
|
|
`,
|
|
lastHitAt: sql<Date>`
|
|
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,
|
|
};
|
|
}
|
|
|
|
await db
|
|
.insert(analyticsDaily)
|
|
.values(rows)
|
|
.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'`));
|
|
}
|