fledged out analytics, self gather, remove vercel speed insights

This commit is contained in:
Michael Freno
2026-01-06 01:34:55 -05:00
parent b118a62f83
commit ea556b3677
13 changed files with 867 additions and 114 deletions

View File

@@ -15,6 +15,14 @@ export interface AnalyticsEntry {
os?: string | null;
sessionId?: string | null;
durationMs?: number | null;
fcp?: number | null;
lcp?: number | null;
cls?: number | null;
fid?: number | null;
inp?: number | null;
ttfb?: number | null;
domLoad?: number | null;
loadComplete?: number | null;
}
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
@@ -23,8 +31,9 @@ export async function logVisit(entry: AnalyticsEntry): Promise<void> {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
country, device_type, browser, os, session_id, duration_ms,
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
uuid(),
entry.userId || null,
@@ -38,7 +47,15 @@ export async function logVisit(entry: AnalyticsEntry): Promise<void> {
entry.browser || null,
entry.os || null,
entry.sessionId || null,
entry.durationMs || null
entry.durationMs || null,
entry.fcp || null,
entry.lcp || null,
entry.cls || null,
entry.fid || null,
entry.inp || null,
entry.ttfb || null,
entry.domLoad || null,
entry.loadComplete || null
]
});
} catch (error) {
@@ -308,6 +325,121 @@ export async function getPathAnalytics(
};
}
export async function getPerformanceStats(days: number = 30): Promise<{
avgLcp: number | null;
avgFcp: number | null;
avgCls: number | null;
avgInp: number | null;
avgTtfb: number | null;
avgDomLoad: number | null;
avgLoadComplete: number | null;
p75Lcp: number | null;
p75Fcp: number | null;
totalWithMetrics: number;
byPath: Array<{
path: string;
avgLcp: number;
avgFcp: number;
avgCls: number;
avgTtfb: number;
count: number;
}>;
}> {
const conn = ConnectionFactory();
// Get average metrics
const avgResult = await conn.execute({
sql: `SELECT
AVG(lcp) as avgLcp,
AVG(fcp) as avgFcp,
AVG(cls) as avgCls,
AVG(inp) as avgInp,
AVG(ttfb) as avgTtfb,
AVG(dom_load) as avgDomLoad,
AVG(load_complete) as avgLoadComplete,
COUNT(*) as total
FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND fcp IS NOT NULL`,
args: []
});
const avgRow = avgResult.rows[0] as any;
// Get 75th percentile for LCP and FCP (approximation using median)
const p75LcpResult = await conn.execute({
sql: `SELECT lcp as p75
FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND lcp IS NOT NULL
ORDER BY lcp
LIMIT 1 OFFSET (
SELECT COUNT(*) * 75 / 100
FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND lcp IS NOT NULL
)`,
args: []
});
const p75FcpResult = await conn.execute({
sql: `SELECT fcp as p75
FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND fcp IS NOT NULL
ORDER BY fcp
LIMIT 1 OFFSET (
SELECT COUNT(*) * 75 / 100
FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND fcp IS NOT NULL
)`,
args: []
});
// Get performance by path (only for non-API paths)
const byPathResult = await conn.execute({
sql: `SELECT
path,
AVG(lcp) as avgLcp,
AVG(fcp) as avgFcp,
AVG(cls) as avgCls,
AVG(ttfb) as avgTtfb,
COUNT(*) as count
FROM VisitorAnalytics
WHERE created_at >= datetime('now', '-${days} days')
AND fcp IS NOT NULL
AND path NOT LIKE '/api/%'
GROUP BY path
ORDER BY count DESC
LIMIT 20`,
args: []
});
const byPath = byPathResult.rows.map((row: any) => ({
path: row.path,
avgLcp: row.avgLcp || 0,
avgFcp: row.avgFcp || 0,
avgCls: row.avgCls || 0,
avgTtfb: row.avgTtfb || 0,
count: row.count
}));
return {
avgLcp: avgRow?.avgLcp || null,
avgFcp: avgRow?.avgFcp || null,
avgCls: avgRow?.avgCls || null,
avgInp: avgRow?.avgInp || null,
avgTtfb: avgRow?.avgTtfb || null,
avgDomLoad: avgRow?.avgDomLoad || null,
avgLoadComplete: avgRow?.avgLoadComplete || null,
p75Lcp: (p75LcpResult.rows[0] as any)?.p75 || null,
p75Fcp: (p75FcpResult.rows[0] as any)?.p75 || null,
totalWithMetrics: avgRow?.total || 0,
byPath
};
}
export async function cleanupOldAnalytics(
olderThanDays: number
): Promise<number> {

View File

@@ -1,13 +1,147 @@
import { createTRPCRouter, adminProcedure } from "../utils";
import { createTRPCRouter, adminProcedure, publicProcedure } from "../utils";
import { z } from "zod";
import {
queryAnalytics,
getAnalyticsSummary,
getPathAnalytics,
cleanupOldAnalytics
cleanupOldAnalytics,
logVisit,
getPerformanceStats
} from "~/server/analytics";
import { ConnectionFactory } from "~/server/database";
export const analyticsRouter = createTRPCRouter({
logPerformance: publicProcedure
.input(
z.object({
path: z.string(),
metrics: z.object({
fcp: z.number().optional(),
lcp: z.number().optional(),
cls: z.number().optional(),
fid: z.number().optional(),
inp: z.number().optional(),
ttfb: z.number().optional(),
domLoad: z.number().optional(),
loadComplete: z.number().optional()
})
})
)
.mutation(async ({ input, ctx }) => {
try {
const conn = ConnectionFactory();
// First, try to find a recent entry for this path without performance data
const checkQuery = await conn.execute({
sql: `SELECT id, path, created_at FROM VisitorAnalytics
WHERE path = ?
AND created_at >= datetime('now', '-5 minutes')
AND fcp IS NULL
ORDER BY created_at DESC
LIMIT 1`,
args: [input.path]
});
if (checkQuery.rows.length > 0) {
const result = await conn.execute({
sql: `UPDATE VisitorAnalytics
SET fcp = ?, lcp = ?, cls = ?, fid = ?, inp = ?, ttfb = ?, dom_load = ?, load_complete = ?
WHERE id = ?`,
args: [
input.metrics.fcp || null,
input.metrics.lcp || null,
input.metrics.cls || null,
input.metrics.fid || null,
input.metrics.inp || null,
input.metrics.ttfb || null,
input.metrics.domLoad || null,
input.metrics.loadComplete || null,
(checkQuery.rows[0] as any).id
]
});
return {
success: true,
rowsAffected: result.rowsAffected,
action: "updated"
};
} else {
const { v4: uuid } = await import("uuid");
const { enrichAnalyticsEntry } = await import("~/server/analytics");
const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent;
const userAgent =
req.headers?.["user-agent"] ||
ctx.event.request?.headers?.get("user-agent") ||
undefined;
const referrer =
req.headers?.referer ||
req.headers?.referrer ||
ctx.event.request?.headers?.get("referer") ||
undefined;
const { getRequestIP } = await import("vinxi/http");
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
const { getCookie } = await import("vinxi/http");
const sessionId =
getCookie(ctx.event.nativeEvent, "session_id") || undefined;
const enriched = enrichAnalyticsEntry({
userId: ctx.userId,
path: input.path,
method: "GET",
userAgent,
referrer,
ipAddress,
sessionId,
fcp: input.metrics.fcp,
lcp: input.metrics.lcp,
cls: input.metrics.cls,
fid: input.metrics.fid,
inp: input.metrics.inp,
ttfb: input.metrics.ttfb,
domLoad: input.metrics.domLoad,
loadComplete: input.metrics.loadComplete
});
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,
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
uuid(),
enriched.userId || null,
enriched.path,
enriched.method,
enriched.referrer || null,
enriched.userAgent || null,
enriched.ipAddress || null,
enriched.country || null,
enriched.deviceType || null,
enriched.browser || null,
enriched.os || null,
enriched.sessionId || null,
enriched.durationMs || null,
enriched.fcp || null,
enriched.lcp || null,
enriched.cls || null,
enriched.fid || null,
enriched.inp || null,
enriched.ttfb || null,
enriched.domLoad || null,
enriched.loadComplete || null
]
});
return { success: true, rowsAffected: 1, action: "created" };
}
} catch (error) {
console.error("Failed to log performance metrics:", error);
return { success: false };
}
}),
getLogs: adminProcedure
.input(
z.object({
@@ -82,5 +216,20 @@ export const analyticsRouter = createTRPCRouter({
deleted,
olderThanDays: input.olderThanDays
};
}),
getPerformanceStats: adminProcedure
.input(
z.object({
days: z.number().min(1).max(365).default(30)
})
)
.query(async ({ input }) => {
const stats = await getPerformanceStats(input.days);
return {
...stats,
timeWindow: `${input.days} days`
};
})
});

View File

@@ -50,17 +50,20 @@ async function createContextInner(event: APIEvent): Promise<Context> {
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
logVisit(
enrichAnalyticsEntry({
userId,
path,
method,
userAgent,
referrer,
ipAddress,
sessionId
})
);
// Don't log the performance logging endpoint itself to avoid circular tracking
if (!path.includes("analytics.logPerformance")) {
logVisit(
enrichAnalyticsEntry({
userId,
path,
method,
userAgent,
referrer,
ipAddress,
sessionId
})
);
}
return {
event,