diff --git a/bun.lockb b/bun.lockb index 9628a29..686ed1c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c924057..9bc5a6d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "@trpc/server": "^10.45.2", "@tursodatabase/api": "^1.9.2", "@typeschema/valibot": "^0.13.4", - "@vercel/speed-insights": "^1.3.1", "bcrypt": "^6.0.0", "es-toolkit": "^1.43.0", "fast-diff": "^1.3.0", diff --git a/src/app.tsx b/src/app.tsx index 48bd74c..2e2499f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -17,6 +17,7 @@ import { DarkModeProvider } from "./context/darkMode"; import { createWindowWidth, isMobile } from "~/lib/resize-utils"; import { MOBILE_CONFIG } from "./config"; import CustomScrollbar from "./components/CustomScrollbar"; +import { initPerformanceTracking } from "~/lib/performance-tracking"; function AppLayout(props: { children: any }) { const { @@ -29,6 +30,9 @@ function AppLayout(props: { children: any }) { let lastScrollY = 0; onMount(() => { + // Initialize performance tracking + initPerformanceTracking(); + const windowWidth = createWindowWidth(); createEffect(() => { diff --git a/src/components/blog/PostBodyClient.tsx b/src/components/blog/PostBodyClient.tsx index 10a9f90..15d37ca 100644 --- a/src/components/blog/PostBodyClient.tsx +++ b/src/components/blog/PostBodyClient.tsx @@ -84,6 +84,104 @@ export default function PostBodyClient(props: PostBodyClientProps) { }); }; + const processVideos = () => { + if (!contentRef) return; + + // Handle direct video elements + const videoElements = contentRef.querySelectorAll("video"); + + videoElements.forEach((video) => { + // Ensure videos play inline and don't trigger downloads + video.setAttribute("playsinline", ""); + video.setAttribute("controls", ""); + + // Remove download attribute if present + video.removeAttribute("download"); + + // Ensure proper MIME types on source elements + const sources = video.querySelectorAll("source"); + sources.forEach((source) => { + const src = source.getAttribute("src"); + if (src) { + // Remove download attribute from sources + source.removeAttribute("download"); + + // Set correct type attribute if missing + if (!source.hasAttribute("type")) { + if (src.endsWith(".mp4")) { + source.setAttribute("type", "video/mp4"); + } else if (src.endsWith(".webm")) { + source.setAttribute("type", "video/webm"); + } else if (src.endsWith(".ogg")) { + source.setAttribute("type", "video/ogg"); + } + } + } + }); + + // If video has direct src attribute, ensure type is set + const videoSrc = video.getAttribute("src"); + if (videoSrc && !video.hasAttribute("type")) { + if (videoSrc.endsWith(".mp4")) { + video.setAttribute("type", "video/mp4"); + } else if (videoSrc.endsWith(".webm")) { + video.setAttribute("type", "video/webm"); + } else if (videoSrc.endsWith(".ogg")) { + video.setAttribute("type", "video/ogg"); + } + } + }); + + // Handle iframes with video sources - replace with proper video tags + const iframes = contentRef.querySelectorAll("iframe"); + iframes.forEach((iframe) => { + const src = iframe.getAttribute("src"); + if ( + src && + (src.endsWith(".mp4") || + src.endsWith(".mov") || + src.endsWith(".webm") || + src.endsWith(".ogg")) + ) { + // Create a proper video element + const video = document.createElement("video"); + video.setAttribute("controls", ""); + video.setAttribute("playsinline", ""); + video.setAttribute("preload", "metadata"); + video.style.maxWidth = "100%"; + video.style.height = "auto"; + + // Set appropriate type based on file extension + let videoType = "video/mp4"; + if (src.endsWith(".mov")) { + videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4 + } else if (src.endsWith(".webm")) { + videoType = "video/webm"; + } else if (src.endsWith(".ogg")) { + videoType = "video/ogg"; + } + + video.setAttribute("type", videoType); + video.src = src; + + // Replace the iframe with the video element + const parent = iframe.parentElement; + if (parent) { + parent.replaceChild(video, iframe); + } + } + }); + + // Also check for any anchor tags wrapping videos that might have download attribute + const videoLinks = contentRef.querySelectorAll("a"); + videoLinks.forEach((link) => { + const hasVideo = link.querySelector("video"); + if (hasVideo) { + link.removeAttribute("download"); + } + }); + }; + const processReferences = () => { if (!contentRef) return; @@ -235,6 +333,7 @@ export default function PostBodyClient(props: PostBodyClientProps) { onMount(() => { setTimeout(() => { + processVideos(); processReferences(); if (props.hasCodeBlock) { processCodeBlocks(); @@ -286,6 +385,7 @@ export default function PostBodyClient(props: PostBodyClientProps) { createEffect(() => { if (props.body && contentRef) { setTimeout(() => { + processVideos(); processReferences(); if (props.hasCodeBlock) { processCodeBlocks(); diff --git a/src/components/blog/TextEditor.tsx b/src/components/blog/TextEditor.tsx index 55e6e5a..497f814 100644 --- a/src/components/blog/TextEditor.tsx +++ b/src/components/blog/TextEditor.tsx @@ -298,8 +298,23 @@ const IframeEmbed = Node.create({ return { setIframe: (options: { src: string }) => - ({ tr, dispatch }) => { + ({ tr, dispatch, editor }) => { const { selection } = tr; + + // Check if the src is a direct video file + const src = options.src || ""; + const isVideoFile = /\.(mp4|mov|webm|ogg)(\?.*)?$/i.test(src); + + if (isVideoFile) { + // Insert a proper video tag instead of iframe + if (dispatch) { + const videoHTML = ``; + editor.commands.insertContent(videoHTML); + } + return true; + } + + // For non-video URLs, create iframe as normal const node = this.type.create(options); if (dispatch) { diff --git a/src/db/types.ts b/src/db/types.ts index 08dacac..f6cff5a 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -150,6 +150,14 @@ export interface VisitorAnalytics { os?: string | null; session_id?: string | null; duration_ms?: number | null; + fcp?: number | null; + lcp?: number | null; + cls?: number | null; + fid?: number | null; + inp?: number | null; + ttfb?: number | null; + dom_load?: number | null; + load_complete?: number | null; created_at: string; } diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 40c2305..22c7ee5 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,5 +1,4 @@ // @refresh reload -import { injectSpeedInsights } from "@vercel/speed-insights"; import { mount, StartClient } from "@solidjs/start/client"; // Handle chunk loading failures from stale cache @@ -26,5 +25,4 @@ window.addEventListener("unhandledrejection", (event) => { } }); -injectSpeedInsights(); mount(() => , document.getElementById("app")!); diff --git a/src/lib/performance-tracking.ts b/src/lib/performance-tracking.ts new file mode 100644 index 0000000..36a0e97 --- /dev/null +++ b/src/lib/performance-tracking.ts @@ -0,0 +1,163 @@ +/** + * Real User Monitoring (RUM) - Client-side performance tracking + * Captures Core Web Vitals and sends to analytics endpoint + */ + +interface PerformanceMetrics { + fcp?: number; + lcp?: number; + cls?: number; + fid?: number; + inp?: number; + ttfb?: number; + domLoad?: number; + loadComplete?: number; +} + +let metrics: PerformanceMetrics = {}; +let clsValue = 0; +let clsEntries: number[] = []; +let inpValue = 0; + +export function initPerformanceTracking() { + if (typeof window === "undefined" || !("PerformanceObserver" in window)) { + return; + } + + // Observe LCP + try { + const lcpObserver = new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + const lastEntry = entries[entries.length - 1] as any; + metrics.lcp = lastEntry.renderTime || lastEntry.loadTime; + }); + lcpObserver.observe({ type: "largest-contentful-paint", buffered: true }); + } catch (e) { + console.debug("LCP not supported"); + } + + // Observe CLS + try { + const clsObserver = new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + const layoutShift = entry as any; + if (!layoutShift.hadRecentInput) { + clsValue += layoutShift.value; + clsEntries.push(layoutShift.value); + } + } + metrics.cls = clsValue; + }); + clsObserver.observe({ type: "layout-shift", buffered: true }); + } catch (e) { + console.debug("CLS not supported"); + } + + // Observe FID + try { + const fidObserver = new PerformanceObserver((entryList) => { + const firstInput = entryList.getEntries()[0] as any; + if (firstInput) { + metrics.fid = firstInput.processingStart - firstInput.startTime; + } + }); + fidObserver.observe({ type: "first-input", buffered: true }); + } catch (e) { + console.debug("FID not supported"); + } + + // Observe INP (event timing) + try { + const interactions: number[] = []; + const inpObserver = new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + const eventEntry = entry as any; + if (eventEntry.interactionId) { + interactions.push(eventEntry.duration); + const sorted = [...interactions].sort((a, b) => b - a); + const p98Index = Math.floor(sorted.length * 0.02); + inpValue = sorted[p98Index] || sorted[0] || 0; + metrics.inp = inpValue; + } + } + }); + inpObserver.observe({ type: "event", buffered: true }); + } catch (e) { + console.debug("INP not supported"); + } + + // Get navigation timing metrics + window.addEventListener("load", () => { + setTimeout(() => { + const navTiming = performance.getEntriesByType( + "navigation" + )[0] as PerformanceNavigationTiming; + + if (navTiming) { + metrics.ttfb = navTiming.responseStart - navTiming.requestStart; + metrics.domLoad = + navTiming.domContentLoadedEventEnd - navTiming.fetchStart; + metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart; + } + + // Get FCP + const paintEntries = performance.getEntriesByType("paint"); + const fcpEntry = paintEntries.find( + (entry) => entry.name === "first-contentful-paint" + ); + if (fcpEntry) { + metrics.fcp = fcpEntry.startTime; + } + + // Send metrics after a short delay to ensure all metrics are captured + setTimeout(() => { + sendMetrics(); + }, 2000); + }, 0); + }); + + // Send metrics before page unload (in case user navigates away) + window.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + sendMetrics(); + } + }); +} + +function sendMetrics() { + // Only send if we have at least one metric + if (Object.keys(metrics).length === 0) { + return; + } + + const path = window.location.pathname + window.location.search; + + // tRPC batch format for public procedure + const tRPCPayload = { + 0: { + path: path, + metrics: { ...metrics } + } + }; + + const apiUrl = "/api/trpc/analytics.logPerformance?batch=1"; + const payload = JSON.stringify(tRPCPayload); + + if (navigator.sendBeacon) { + const blob = new Blob([payload], { type: "application/json" }); + navigator.sendBeacon(apiUrl, blob); + } else { + // Fallback to fetch with keepalive + fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + keepalive: true + }).catch((err) => + console.debug("Failed to send performance metrics:", err) + ); + } + + // Clear metrics after sending + metrics = {}; +} diff --git a/src/routes/analytics.tsx b/src/routes/analytics.tsx index 8d019a4..0e683d6 100644 --- a/src/routes/analytics.tsx +++ b/src/routes/analytics.tsx @@ -30,13 +30,13 @@ interface PerformanceTarget { } const PERFORMANCE_TARGETS: Record = { - lcp: { good: 2500, acceptable: 4000, label: "LCP", unit: "ms" }, - fcp: { good: 1800, acceptable: 3000, label: "FCP", unit: "ms" }, - ttfb: { good: 800, acceptable: 1800, label: "TTFB", unit: "ms" }, - cls: { good: 0.1, acceptable: 0.25, label: "CLS", unit: "" }, + lcp: { good: 1500, acceptable: 2500, label: "LCP", unit: "ms" }, + fcp: { good: 1000, acceptable: 1800, label: "FCP", unit: "ms" }, + ttfb: { good: 500, acceptable: 800, label: "TTFB", unit: "ms" }, + cls: { good: 0.05, acceptable: 0.1, label: "CLS", unit: "" }, avgDuration: { - good: 3000, - acceptable: 5000, + good: 2000, + acceptable: 3000, label: "Avg Duration", unit: "ms" } @@ -57,22 +57,22 @@ function getPerformanceRating( function getRatingColor(rating: "good" | "acceptable" | "poor"): string { switch (rating) { case "good": - return "text-green-600 dark:text-green-400"; + return "text-green"; case "acceptable": - return "text-yellow-600 dark:text-yellow-400"; + return "text-yellow"; case "poor": - return "text-red-600 dark:text-red-400"; + return "text-red"; } } function getRatingBgColor(rating: "good" | "acceptable" | "poor"): string { switch (rating) { case "good": - return "bg-green-100 dark:bg-green-900/30"; + return "bg-green/10"; case "acceptable": - return "bg-yellow-100 dark:bg-yellow-900/30"; + return "bg-yellow/10"; case "poor": - return "bg-red-100 dark:bg-red-900/30"; + return "bg-red/10"; } } @@ -87,8 +87,6 @@ function formatNumber(num: number): string { } export default function AnalyticsPage() { - const adminCheck = createAsync(() => checkAdmin()); - const [timeWindow, setTimeWindow] = createSignal(7); const [selectedPath, setSelectedPath] = createSignal(null); const [error, setError] = createSignal(null); @@ -103,6 +101,17 @@ export default function AnalyticsPage() { } }); + const performanceStats = createAsync(async () => { + try { + return await api.analytics.getPerformanceStats.query({ + days: timeWindow() + }); + } catch (e) { + console.error("Failed to load performance stats:", e); + return null; + } + }); + const pathStats = createAsync(async () => { const path = selectedPath(); if (!path) return null; @@ -120,13 +129,13 @@ export default function AnalyticsPage() { return ( <> Analytics Dashboard - Admin -
+
-

+

Analytics Dashboard

-

+

Visitor analytics and performance metrics

@@ -139,8 +148,8 @@ export default function AnalyticsPage() { onClick={() => setTimeWindow(days)} class={`rounded-lg px-4 py-2 font-medium transition-colors ${ timeWindow() === days - ? "bg-blue-600 text-white" - : "bg-white text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + ? "bg-blue text-base" + : "bg-surface0 text-text hover:bg-surface1 border-surface1 border" }`} > {days === 1 ? "24h" : `${days}d`} @@ -150,7 +159,7 @@ export default function AnalyticsPage() {
-
+

Error loading analytics

{error()}

@@ -161,53 +170,218 @@ export default function AnalyticsPage() { <> {/* Overview Cards */}
-
-
- Total Requests -
-
+
+
Total Requests
+
{formatNumber(data().totalVisits)}
-
+
{formatNumber(data().totalPageVisits)} pages,{" "} {formatNumber(data().totalApiCalls)} API
-
-
+
+
Unique Visitors
-
+
{formatNumber(data().uniqueVisitors)}
-
-
+
+
Authenticated Users
-
+
{formatNumber(data().uniqueUsers)}
-
-
+
+
Avg. Visits/Day
-
+
{formatNumber(data().totalVisits / timeWindow())}
- {/* Top Pages */} -
-
-

- Top Pages + {/* Performance Metrics Section */} + 0 + } + > +
+

+ Core Web Vitals

+ + {/* Performance Overview Cards */} +
+ +
+
+ LCP (Largest Contentful Paint) +
+
+ {Math.round(performanceStats()!.avgLcp!)}ms +
+
+ Target: <1.5s (good), <2.5s (ok) +
+
+
+ + +
+
+ FCP (First Contentful Paint) +
+
+ {Math.round(performanceStats()!.avgFcp!)}ms +
+
+ Target: <1s (good), <1.8s (ok) +
+
+
+ + +
+
+ CLS (Cumulative Layout Shift) +
+
+ {performanceStats()!.avgCls!.toFixed(3)} +
+
+ Target: <0.05 (good), <0.1 (ok) +
+
+
+ + +
+
+ TTFB (Time to First Byte) +
+
+ {Math.round(performanceStats()!.avgTtfb!)}ms +
+
+ Target: <500ms (good), <800ms (ok) +
+
+
+
+ + {/* Performance by Page */} + 0 + } + > +
+
+

+ Performance by Page +

+

+ {performanceStats()!.totalWithMetrics} page loads + with performance data +

+
+
+
+ + + + + + + + + + + + + + {(page) => ( + + + + + + + + + )} + + +
Page + LCP + + FCP + + CLS + + TTFB + + Samples +
+ {page.path} + + {Math.round(page.avgLcp)}ms + + {Math.round(page.avgFcp)}ms + + {page.avgCls.toFixed(3)} + + {Math.round(page.avgTtfb)}ms + + {page.count} +
+
+
+
+
+
+
+ + {/* Top Pages */} +
+
+

Top Pages

@@ -217,24 +391,24 @@ export default function AnalyticsPage() { (pathData.count / data().totalPageVisits) * 100; return (
setSelectedPath(pathData.path)} >
- + {pathData.path} - + {formatNumber(pathData.count)} visits
-
+
-
+
{percentage.toFixed(1)}% of page traffic
@@ -246,11 +420,9 @@ export default function AnalyticsPage() {
{/* Top API Calls */} -
-
-

- Top API Calls -

+
+
+

Top API Calls

@@ -261,20 +433,20 @@ export default function AnalyticsPage() { return (
- + {apiData.path} - + {formatNumber(apiData.count)}
-
+
-
+
{percentage.toFixed(1)}% of API traffic
@@ -288,11 +460,9 @@ export default function AnalyticsPage() { {/* Device & Browser Stats */}
{/* Device Types */} -
-
-

- Device Types -

+
+
+

Device Types

@@ -312,12 +482,12 @@ export default function AnalyticsPage() { {device.type} - + {formatNumber(device.count)} ( {percentage.toFixed(1)}%)
-
+
{/* Browsers */} -
-
-

- Browsers -

+
+
+

Browsers

@@ -356,12 +524,12 @@ export default function AnalyticsPage() { {browser.browser} - + {formatNumber(browser.count)} ( {percentage.toFixed(1)}%)
-
+
0}> -
-
-

+
+
+

Top Referrers

@@ -388,11 +556,11 @@ export default function AnalyticsPage() {
{(referrer) => ( -
- +
+ {referrer.referrer} - + {formatNumber(referrer.count)}
@@ -409,14 +577,14 @@ export default function AnalyticsPage() { {/* Path Details Modal/Section */} {(stats) => ( -
-
-

+
+
+

Path Details: {selectedPath()}

@@ -424,26 +592,20 @@ export default function AnalyticsPage() {
-
- Total Visits -
-
+
Total Visits
+
{formatNumber(stats().totalVisits)}
-
- Unique Visitors -
-
+
Unique Visitors
+
{formatNumber(stats().uniqueVisitors)}
-
- Avg. Duration -
-
+
Avg. Duration
+
{stats().avgDurationMs ? `${(stats().avgDurationMs! / 1000).toFixed(1)}s` : "N/A"} @@ -454,7 +616,7 @@ export default function AnalyticsPage() { {/* Visits by Day */} 0}>
-

+

Visits by Day

@@ -467,14 +629,14 @@ export default function AnalyticsPage() { return (
- + {new Date(day.date).toLocaleDateString()} - + {formatNumber(day.count)}
-
+
{ @@ -23,8 +31,9 @@ export async function logVisit(entry: AnalyticsEntry): Promise { 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 { 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 { diff --git a/src/server/api/routers/analytics.ts b/src/server/api/routers/analytics.ts index d8a04e8..8f9c138 100644 --- a/src/server/api/routers/analytics.ts +++ b/src/server/api/routers/analytics.ts @@ -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` + }; }) }); diff --git a/src/server/api/utils.ts b/src/server/api/utils.ts index 11c940e..f473986 100644 --- a/src/server/api/utils.ts +++ b/src/server/api/utils.ts @@ -50,17 +50,20 @@ async function createContextInner(event: APIEvent): Promise { 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,