diff --git a/backend/app.ts b/backend/app.ts index 6bce1cd..b82157a 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -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")), diff --git a/backend/configs/umami.config.ts b/backend/configs/umami.config.ts new file mode 100644 index 0000000..1bd0d71 --- /dev/null +++ b/backend/configs/umami.config.ts @@ -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); +} diff --git a/backend/db/schema/analytics.schema.ts b/backend/db/schema/analytics.schema.ts new file mode 100644 index 0000000..a1e1fe0 --- /dev/null +++ b/backend/db/schema/analytics.schema.ts @@ -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"), +}); diff --git a/backend/db/schema/dailyAnalytics.schema.ts b/backend/db/schema/dailyAnalytics.schema.ts new file mode 100644 index 0000000..e1eaa98 --- /dev/null +++ b/backend/db/schema/dailyAnalytics.schema.ts @@ -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(), +}); diff --git a/backend/utils/analyticRouteHits.utils.ts b/backend/utils/analyticRouteHits.utils.ts index ac8d7fa..1aa1c1a 100644 --- a/backend/utils/analyticRouteHits.utils.ts +++ b/backend/utils/analyticRouteHits.utils.ts @@ -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 { + 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`${businessDate}`, + 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, + }; + } + + 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'`)); +} diff --git a/backend/utils/umami.utils.ts b/backend/utils/umami.utils.ts new file mode 100644 index 0000000..9545711 --- /dev/null +++ b/backend/utils/umami.utils.ts @@ -0,0 +1,61 @@ +import { isUmamiEnabled, umamiConfig } from "../configs/umami.config.js"; + +type TrackLstEventInput = { + eventName: string; + eventData?: Record; + url?: string; + hostname?: string; +}; + +export async function trackLstEvent({ + eventName, + eventData, + url = "/backend", + hostname = umamiConfig.server, +}: TrackLstEventInput): Promise { + 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, + }, +}); + +*/ diff --git a/frontend/index.html b/frontend/index.html index b22d5e8..eae9bd0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,15 @@ Logistics Support Tool +
+ + diff --git a/frontend/src/lib/umami.utils.ts b/frontend/src/lib/umami.utils.ts new file mode 100644 index 0000000..992bbac --- /dev/null +++ b/frontend/src/lib/umami.utils.ts @@ -0,0 +1,65 @@ +type RuntimeConfig = { + appName: string; + site: string; + server: string; + appVersion: string; + umamiHost: string; + umamiWebsiteId: string; +}; + +declare global { + interface Window { + LST_CONFIG?: RuntimeConfig; + umami?: { + track: (eventName: string, eventData?: Record) => void; + }; + } +} + +export const runtimeConfig: RuntimeConfig = { + appName: window.LST_CONFIG?.appName ?? "LST", + site: window.LST_CONFIG?.site ?? "unknown", + server: window.LST_CONFIG?.server ?? "unknown", + appVersion: window.LST_CONFIG?.appVersion ?? "dev", + umamiHost: window.LST_CONFIG?.umamiHost ?? "", + umamiWebsiteId: window.LST_CONFIG?.umamiWebsiteId ?? "", +}; + +export function loadUmami() { + if (!runtimeConfig.umamiHost) return; + if (!runtimeConfig.umamiWebsiteId) return; + if (document.querySelector("script[data-website-id]")) return; + + const script = document.createElement("script"); + script.defer = true; + script.src = `${runtimeConfig.umamiHost}/script.js`; + script.setAttribute("data-website-id", runtimeConfig.umamiWebsiteId); + + document.head.appendChild(script); +} + +export function trackLstEvent( + eventName: string, + eventData?: Record, +) { + window.umami?.track(eventName, { + app: runtimeConfig.appName, + site: runtimeConfig.site, + server: runtimeConfig.server, + appVersion: runtimeConfig.appVersion, + ...eventData, + }); +} + +/* + +event type + + trackLstEvent("exampleClick", { + module: "example", + action: "test_click", + label: "Example Button", + page: window.location.pathname, + }); + +*/ diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d781095..632db32 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import ReactDOM from "react-dom/client"; import "./index.css"; import { createRouter, RouterProvider } from "@tanstack/react-router"; import socket from "./lib/socket.io"; +import { loadUmami } from "./lib/umami.utils"; // Import the generated route tree import { routeTree } from "./routeTree.gen"; @@ -38,6 +39,7 @@ declare module "@tanstack/react-router" { // Render the app const rootElement = document.getElementById("root")!; if (!rootElement.innerHTML) { + loadUmami(); const root = ReactDOM.createRoot(rootElement); root.render( diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 2183636..b706802 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import z from "zod"; import { useSession } from "../lib/auth-client"; +import { trackLstEvent } from "../lib/umami.utils"; export const Route = createFileRoute("/")({ validateSearch: z.object({ @@ -27,6 +28,16 @@ function Index() { url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; } + //test tracking + const click = () => { + trackLstEvent("silly_click", { + module: "silly", + action: "click", + label: "rick rolled", + page: window.location.pathname, + }); + }; + return (

Welcome Lst - V3

@@ -43,16 +54,18 @@ function Index() { Click - - - - Here - - + {" "} +

);