refactor(analyitics): finished analyitics as a base
This commit is contained in:
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
import { umamiConfig } from "./configs/umami.config.js";
|
||||
import { createLogger } from "./logger/logger.controller.js";
|
||||
import { setupRoutes } from "./routeHandler.routes.js";
|
||||
import { auth } from "./utils/auth.utils.js";
|
||||
@@ -33,6 +34,22 @@ const createApp = async () => {
|
||||
app.use(express.json());
|
||||
setupRoutes(baseUrl, app);
|
||||
|
||||
app.get(`${baseUrl}/api/lst-config.js`, (_, res) => {
|
||||
res.type("application/javascript");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
res.send(`
|
||||
window.LST_CONFIG = {
|
||||
appName: ${JSON.stringify(umamiConfig.appName ?? "LST")},
|
||||
site: ${JSON.stringify(umamiConfig.site ?? "unknown")},
|
||||
server: ${JSON.stringify(umamiConfig.server ?? "unknown")},
|
||||
appVersion: ${JSON.stringify(umamiConfig.appVersion ?? "dev")},
|
||||
umamiHost: ${JSON.stringify(umamiConfig.umamiHost ?? "")},
|
||||
umamiWebsiteId: ${JSON.stringify(umamiConfig.umamiWebsiteId ?? "")}
|
||||
};
|
||||
`);
|
||||
});
|
||||
|
||||
app.use(
|
||||
`${baseUrl}/app`,
|
||||
express.static(join(__dirname, "../frontend/dist")),
|
||||
|
||||
21
backend/configs/umami.config.ts
Normal file
21
backend/configs/umami.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type UmamiRuntimeConfig = {
|
||||
appName: string;
|
||||
site: string;
|
||||
server: string;
|
||||
appVersion: string;
|
||||
umamiHost: string;
|
||||
umamiWebsiteId: string;
|
||||
};
|
||||
|
||||
export const umamiConfig: UmamiRuntimeConfig = {
|
||||
appName: process.env.APP_NAME ?? "LST",
|
||||
site: process.env.URL ?? "unknown",
|
||||
server: process.env.PROD_PLANT_TOKEN ?? "unknown", // could also be server name based on our setup.
|
||||
appVersion: process.env.NODE_ENV ?? "dev",
|
||||
umamiHost: process.env.UMAMI_HOST ?? "",
|
||||
umamiWebsiteId: process.env.UMAMI_WEBSITE_ID ?? "",
|
||||
};
|
||||
|
||||
export function isUmamiEnabled() {
|
||||
return Boolean(umamiConfig.umamiHost && umamiConfig.umamiWebsiteId);
|
||||
}
|
||||
21
backend/db/schema/analytics.schema.ts
Normal file
21
backend/db/schema/analytics.schema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
export const analytics = pgTable("analytics", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
|
||||
method: text("method").notNull(),
|
||||
routePattern: text("route_pattern").notNull(),
|
||||
actualPath: text("actual_path").notNull(),
|
||||
|
||||
statusCode: integer("status_code").notNull(),
|
||||
durationMs: integer("duration_ms").notNull(),
|
||||
|
||||
module: text("module"),
|
||||
userId: text("user_id"),
|
||||
userEmail: text("user_email"),
|
||||
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
});
|
||||
33
backend/db/schema/dailyAnalytics.schema.ts
Normal file
33
backend/db/schema/dailyAnalytics.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
date,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const analyticsDaily = pgTable("analytics_daily", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
businessDate: date("business_date").notNull(),
|
||||
|
||||
method: text("method").notNull(),
|
||||
routePattern: text("route_pattern").notNull(),
|
||||
module: text("module").notNull(),
|
||||
|
||||
totalHits: integer("total_hits").notNull(),
|
||||
uniqueUsers: integer("unique_users").notNull(),
|
||||
|
||||
successCount: integer("success_count").notNull(),
|
||||
errorCount: integer("error_count").notNull(),
|
||||
|
||||
avgDurationMs: integer("avg_duration_ms").notNull(),
|
||||
maxDurationMs: integer("max_duration_ms").notNull(),
|
||||
|
||||
firstHitAt: timestamp("first_hit_at").notNull(),
|
||||
lastHitAt: timestamp("last_hit_at").notNull(),
|
||||
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
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",
|
||||
@@ -29,3 +31,111 @@ type CreateRouteHitInput = {
|
||||
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'`));
|
||||
}
|
||||
|
||||
61
backend/utils/umami.utils.ts
Normal file
61
backend/utils/umami.utils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { isUmamiEnabled, umamiConfig } from "../configs/umami.config.js";
|
||||
|
||||
type TrackLstEventInput = {
|
||||
eventName: string;
|
||||
eventData?: Record<string, unknown>;
|
||||
url?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
export async function trackLstEvent({
|
||||
eventName,
|
||||
eventData,
|
||||
url = "/backend",
|
||||
hostname = umamiConfig.server,
|
||||
}: TrackLstEventInput): Promise<void> {
|
||||
if (!isUmamiEnabled()) return;
|
||||
|
||||
try {
|
||||
await fetch(`${umamiConfig.umamiHost}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "LST-Backend",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "event",
|
||||
payload: {
|
||||
website: umamiConfig.umamiWebsiteId,
|
||||
name: eventName,
|
||||
url,
|
||||
hostname,
|
||||
language: "en-US",
|
||||
screen: "backend",
|
||||
data: {
|
||||
app: umamiConfig.appName,
|
||||
site: umamiConfig.site,
|
||||
server: umamiConfig.server,
|
||||
appVersion: umamiConfig.appVersion,
|
||||
source: "backend",
|
||||
...eventData,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send Umami backend event", err);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
await trackLstEvent({
|
||||
eventName: "label_print_completed",
|
||||
url: "/backend/printers",
|
||||
eventData: {
|
||||
module: "printers",
|
||||
printerName,
|
||||
labelType,
|
||||
},
|
||||
});
|
||||
|
||||
*/
|
||||
Reference in New Issue
Block a user