analytics page

This commit is contained in:
Michael Freno
2026-01-06 00:40:44 -05:00
parent 61303969e8
commit d2ee61b830
8 changed files with 1012 additions and 4 deletions

369
src/server/analytics.ts Normal file
View File

@@ -0,0 +1,369 @@
import { ConnectionFactory } from "./database";
import { v4 as uuid } from "uuid";
import type { VisitorAnalytics, AnalyticsQuery } from "~/db/types";
export interface AnalyticsEntry {
userId?: string | null;
path: string;
method: string;
referrer?: string | null;
userAgent?: string | null;
ipAddress?: string | null;
country?: string | null;
deviceType?: string | null;
browser?: string | null;
os?: string | null;
sessionId?: string | null;
durationMs?: number | null;
}
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
try {
const conn = ConnectionFactory();
await conn.execute({
sql: `INSERT INTO VisitorAnalytics (
id, user_id, path, method, referrer, user_agent, ip_address,
country, device_type, browser, os, session_id, duration_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
uuid(),
entry.userId || null,
entry.path,
entry.method,
entry.referrer || null,
entry.userAgent || null,
entry.ipAddress || null,
entry.country || null,
entry.deviceType || null,
entry.browser || null,
entry.os || null,
entry.sessionId || null,
entry.durationMs || null
]
});
} catch (error) {
console.error("Failed to log visitor analytics:", error, entry);
}
}
export async function queryAnalytics(
query: AnalyticsQuery
): Promise<VisitorAnalytics[]> {
const conn = ConnectionFactory();
let sql = "SELECT * FROM VisitorAnalytics WHERE 1=1";
const args: any[] = [];
if (query.userId) {
sql += " AND user_id = ?";
args.push(query.userId);
}
if (query.path) {
sql += " AND path = ?";
args.push(query.path);
}
if (query.startDate) {
sql += " AND created_at >= ?";
args.push(
typeof query.startDate === "string"
? query.startDate
: query.startDate.toISOString()
);
}
if (query.endDate) {
sql += " AND created_at <= ?";
args.push(
typeof query.endDate === "string"
? query.endDate
: query.endDate.toISOString()
);
}
sql += " ORDER BY created_at DESC";
if (query.limit) {
sql += " LIMIT ?";
args.push(query.limit);
}
if (query.offset) {
sql += " OFFSET ?";
args.push(query.offset);
}
const result = await conn.execute({ sql, args });
return result.rows.map((row) => ({
id: row.id as string,
user_id: row.user_id as string | null,
path: row.path as string,
method: row.method as string,
referrer: row.referrer as string | null,
user_agent: row.user_agent as string | null,
ip_address: row.ip_address as string | null,
country: row.country as string | null,
device_type: row.device_type as string | null,
browser: row.browser as string | null,
os: row.os as string | null,
session_id: row.session_id as string | null,
duration_ms: row.duration_ms as number | null,
created_at: row.created_at as string
}));
}
export async function getAnalyticsSummary(days: number = 30): Promise<{
totalVisits: number;
totalPageVisits: number;
totalApiCalls: number;
uniqueVisitors: number;
uniqueUsers: number;
topPages: Array<{ path: string; count: number }>;
topApiCalls: Array<{ path: string; count: number }>;
topReferrers: Array<{ referrer: string; count: number }>;
deviceTypes: Array<{ type: string; count: number }>;
browsers: Array<{ browser: string; count: number }>;
}> {
const conn = ConnectionFactory();
const totalVisitsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')`,
args: []
});
const totalVisits = (totalVisitsResult.rows[0]?.count as number) || 0;
const totalPageVisitsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND path NOT LIKE '/api/%'`,
args: []
});
const totalPageVisits = (totalPageVisitsResult.rows[0]?.count as number) || 0;
const totalApiCallsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND path LIKE '/api/%'`,
args: []
});
const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0;
const uniqueVisitorsResult = await conn.execute({
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
WHERE ip_address IS NOT NULL
AND created_at >= datetime('now', '-${days} days')`,
args: []
});
const uniqueVisitors = (uniqueVisitorsResult.rows[0]?.count as number) || 0;
const uniqueUsersResult = await conn.execute({
sql: `SELECT COUNT(DISTINCT user_id) as count FROM VisitorAnalytics
WHERE user_id IS NOT NULL
AND created_at >= datetime('now', '-${days} days')`,
args: []
});
const uniqueUsers = (uniqueUsersResult.rows[0]?.count as number) || 0;
const topPagesResult = await conn.execute({
sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND path NOT LIKE '/api/%'
GROUP BY path
ORDER BY count DESC
LIMIT 10`,
args: []
});
const topPages = topPagesResult.rows.map((row) => ({
path: row.path as string,
count: row.count as number
}));
const topApiCallsResult = await conn.execute({
sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND path LIKE '/api/%'
GROUP BY path
ORDER BY count DESC
LIMIT 10`,
args: []
});
const topApiCalls = topApiCallsResult.rows.map((row) => ({
path: row.path as string,
count: row.count as number
}));
const topReferrersResult = await conn.execute({
sql: `SELECT referrer, COUNT(*) as count FROM VisitorAnalytics
WHERE referrer IS NOT NULL
AND created_at >= datetime('now', '-${days} days')
GROUP BY referrer
ORDER BY count DESC
LIMIT 10`,
args: []
});
const topReferrers = topReferrersResult.rows.map((row) => ({
referrer: row.referrer as string,
count: row.count as number
}));
const deviceTypesResult = await conn.execute({
sql: `SELECT device_type, COUNT(*) as count FROM VisitorAnalytics
WHERE device_type IS NOT NULL
AND created_at >= datetime('now', '-${days} days')
GROUP BY device_type
ORDER BY count DESC`,
args: []
});
const deviceTypes = deviceTypesResult.rows.map((row) => ({
type: row.device_type as string,
count: row.count as number
}));
const browsersResult = await conn.execute({
sql: `SELECT browser, COUNT(*) as count FROM VisitorAnalytics
WHERE browser IS NOT NULL
AND created_at >= datetime('now', '-${days} days')
GROUP BY browser
ORDER BY count DESC
LIMIT 10`,
args: []
});
const browsers = browsersResult.rows.map((row) => ({
browser: row.browser as string,
count: row.count as number
}));
return {
totalVisits,
totalPageVisits,
totalApiCalls,
uniqueVisitors,
uniqueUsers,
topPages,
topApiCalls,
topReferrers,
deviceTypes,
browsers
};
}
export async function getPathAnalytics(
path: string,
days: number = 30
): Promise<{
totalVisits: number;
uniqueVisitors: number;
avgDurationMs: number | null;
visitsByDay: Array<{ date: string; count: number }>;
}> {
const conn = ConnectionFactory();
const totalVisitsResult = await conn.execute({
sql: `SELECT COUNT(*) as count FROM VisitorAnalytics
WHERE path = ?
AND created_at >= datetime('now', '-${days} days')`,
args: [path]
});
const totalVisits = (totalVisitsResult.rows[0]?.count as number) || 0;
const uniqueVisitorsResult = await conn.execute({
sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics
WHERE path = ?
AND ip_address IS NOT NULL
AND created_at >= datetime('now', '-${days} days')`,
args: [path]
});
const uniqueVisitors = (uniqueVisitorsResult.rows[0]?.count as number) || 0;
const avgDurationResult = await conn.execute({
sql: `SELECT AVG(duration_ms) as avg FROM VisitorAnalytics
WHERE path = ?
AND duration_ms IS NOT NULL
AND created_at >= datetime('now', '-${days} days')`,
args: [path]
});
const avgDurationMs = avgDurationResult.rows[0]?.avg as number | null;
const visitsByDayResult = await conn.execute({
sql: `SELECT DATE(created_at) as date, COUNT(*) as count
FROM VisitorAnalytics
WHERE path = ?
AND created_at >= datetime('now', '-${days} days')
GROUP BY DATE(created_at)
ORDER BY date DESC`,
args: [path]
});
const visitsByDay = visitsByDayResult.rows.map((row) => ({
date: row.date as string,
count: row.count as number
}));
return {
totalVisits,
uniqueVisitors,
avgDurationMs,
visitsByDay
};
}
export async function cleanupOldAnalytics(
olderThanDays: number
): Promise<number> {
const conn = ConnectionFactory();
const result = await conn.execute({
sql: `DELETE FROM VisitorAnalytics
WHERE created_at < datetime('now', '-${olderThanDays} days')
RETURNING id`,
args: []
});
return result.rows.length;
}
function parseUserAgent(userAgent?: string): {
deviceType: string | null;
browser: string | null;
os: string | null;
} {
if (!userAgent) {
return { deviceType: null, browser: null, os: null };
}
const ua = userAgent.toLowerCase();
let deviceType: string | null = "desktop";
if (ua.includes("mobile")) deviceType = "mobile";
else if (ua.includes("tablet") || ua.includes("ipad")) deviceType = "tablet";
let browser: string | null = null;
if (ua.includes("edg")) browser = "edge";
else if (ua.includes("chrome")) browser = "chrome";
else if (ua.includes("firefox")) browser = "firefox";
else if (ua.includes("safari") && !ua.includes("chrome")) browser = "safari";
else if (ua.includes("opera") || ua.includes("opr")) browser = "opera";
let os: string | null = null;
if (ua.includes("windows")) os = "windows";
else if (ua.includes("mac")) os = "macos";
else if (ua.includes("linux")) os = "linux";
else if (ua.includes("android")) os = "android";
else if (ua.includes("iphone") || ua.includes("ipad")) os = "ios";
return { deviceType, browser, os };
}
export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry {
const { deviceType, browser, os } = parseUserAgent(
entry.userAgent || undefined
);
return {
...entry,
deviceType: entry.deviceType || deviceType,
browser: entry.browser || browser,
os: entry.os || os
};
}

View File

@@ -1,5 +1,6 @@
import { authRouter } from "./routers/auth";
import { auditRouter } from "./routers/audit";
import { analyticsRouter } from "./routers/analytics";
import { databaseRouter } from "./routers/database";
import { lineageRouter } from "./routers/lineage";
import { miscRouter } from "./routers/misc";
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
auth: authRouter,
audit: auditRouter,
analytics: analyticsRouter,
database: databaseRouter,
lineage: lineageRouter,
misc: miscRouter,

View File

@@ -0,0 +1,86 @@
import { createTRPCRouter, adminProcedure } from "../utils";
import { z } from "zod";
import {
queryAnalytics,
getAnalyticsSummary,
getPathAnalytics,
cleanupOldAnalytics
} from "~/server/analytics";
export const analyticsRouter = createTRPCRouter({
getLogs: adminProcedure
.input(
z.object({
userId: z.string().optional(),
path: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
limit: z.number().min(1).max(1000).default(100),
offset: z.number().min(0).default(0)
})
)
.query(async ({ input }) => {
const logs = await queryAnalytics({
userId: input.userId,
path: input.path,
startDate: input.startDate,
endDate: input.endDate,
limit: input.limit,
offset: input.offset
});
return {
logs,
count: logs.length,
offset: input.offset,
limit: input.limit
};
}),
getSummary: adminProcedure
.input(
z.object({
days: z.number().min(1).max(365).default(30)
})
)
.query(async ({ input }) => {
const summary = await getAnalyticsSummary(input.days);
return {
...summary,
timeWindow: `${input.days} days`
};
}),
getPathStats: adminProcedure
.input(
z.object({
path: z.string(),
days: z.number().min(1).max(365).default(30)
})
)
.query(async ({ input }) => {
const stats = await getPathAnalytics(input.path, input.days);
return {
path: input.path,
...stats,
timeWindow: `${input.days} days`
};
}),
cleanup: adminProcedure
.input(
z.object({
olderThanDays: z.number().min(1).max(365).default(90)
})
)
.mutation(async ({ input }) => {
const deleted = await cleanupOldAnalytics(input.olderThanDays);
return {
deleted,
olderThanDays: input.olderThanDays
};
})
});

View File

@@ -3,6 +3,8 @@ import type { APIEvent } from "@solidjs/start/server";
import { getCookie, setCookie } from "vinxi/http";
import { jwtVerify, type JWTPayload } from "jose";
import { env } from "~/env/server";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { getRequestIP } from "vinxi/http";
export type Context = {
event: APIEvent;
@@ -33,6 +35,33 @@ async function createContextInner(event: APIEvent): Promise<Context> {
}
}
const req = event.nativeEvent.node?.req || event.nativeEvent;
const path = req.url || event.request?.url || "unknown";
const method = req.method || event.request?.method || "GET";
const userAgent =
req.headers?.["user-agent"] ||
event.request?.headers?.get("user-agent") ||
undefined;
const referrer =
req.headers?.referer ||
req.headers?.referrer ||
event.request?.headers?.get("referer") ||
undefined;
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
logVisit(
enrichAnalyticsEntry({
userId,
path,
method,
userAgent,
referrer,
ipAddress,
sessionId
})
);
return {
event,
userId,