fledged out analytics, self gather, remove vercel speed insights
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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`
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user