fledged out analytics, self gather, remove vercel speed insights
This commit is contained in:
@@ -47,7 +47,6 @@
|
|||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"@tursodatabase/api": "^1.9.2",
|
"@tursodatabase/api": "^1.9.2",
|
||||||
"@typeschema/valibot": "^0.13.4",
|
"@typeschema/valibot": "^0.13.4",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"fast-diff": "^1.3.0",
|
"fast-diff": "^1.3.0",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { DarkModeProvider } from "./context/darkMode";
|
|||||||
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
||||||
import { MOBILE_CONFIG } from "./config";
|
import { MOBILE_CONFIG } from "./config";
|
||||||
import CustomScrollbar from "./components/CustomScrollbar";
|
import CustomScrollbar from "./components/CustomScrollbar";
|
||||||
|
import { initPerformanceTracking } from "~/lib/performance-tracking";
|
||||||
|
|
||||||
function AppLayout(props: { children: any }) {
|
function AppLayout(props: { children: any }) {
|
||||||
const {
|
const {
|
||||||
@@ -29,6 +30,9 @@ function AppLayout(props: { children: any }) {
|
|||||||
let lastScrollY = 0;
|
let lastScrollY = 0;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// Initialize performance tracking
|
||||||
|
initPerformanceTracking();
|
||||||
|
|
||||||
const windowWidth = createWindowWidth();
|
const windowWidth = createWindowWidth();
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -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 = () => {
|
const processReferences = () => {
|
||||||
if (!contentRef) return;
|
if (!contentRef) return;
|
||||||
|
|
||||||
@@ -235,6 +333,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
processVideos();
|
||||||
processReferences();
|
processReferences();
|
||||||
if (props.hasCodeBlock) {
|
if (props.hasCodeBlock) {
|
||||||
processCodeBlocks();
|
processCodeBlocks();
|
||||||
@@ -286,6 +385,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.body && contentRef) {
|
if (props.body && contentRef) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
processVideos();
|
||||||
processReferences();
|
processReferences();
|
||||||
if (props.hasCodeBlock) {
|
if (props.hasCodeBlock) {
|
||||||
processCodeBlocks();
|
processCodeBlocks();
|
||||||
|
|||||||
@@ -298,8 +298,23 @@ const IframeEmbed = Node.create<IframeOptions>({
|
|||||||
return {
|
return {
|
||||||
setIframe:
|
setIframe:
|
||||||
(options: { src: string }) =>
|
(options: { src: string }) =>
|
||||||
({ tr, dispatch }) => {
|
({ tr, dispatch, editor }) => {
|
||||||
const { selection } = tr;
|
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 = `<video src="${src}" controls playsinline style="max-width: 100%; height: auto;"></video>`;
|
||||||
|
editor.commands.insertContent(videoHTML);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-video URLs, create iframe as normal
|
||||||
const node = this.type.create(options);
|
const node = this.type.create(options);
|
||||||
|
|
||||||
if (dispatch) {
|
if (dispatch) {
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ export interface VisitorAnalytics {
|
|||||||
os?: string | null;
|
os?: string | null;
|
||||||
session_id?: string | null;
|
session_id?: string | null;
|
||||||
duration_ms?: number | 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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
import { injectSpeedInsights } from "@vercel/speed-insights";
|
|
||||||
import { mount, StartClient } from "@solidjs/start/client";
|
import { mount, StartClient } from "@solidjs/start/client";
|
||||||
|
|
||||||
// Handle chunk loading failures from stale cache
|
// Handle chunk loading failures from stale cache
|
||||||
@@ -26,5 +25,4 @@ window.addEventListener("unhandledrejection", (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
injectSpeedInsights();
|
|
||||||
mount(() => <StartClient />, document.getElementById("app")!);
|
mount(() => <StartClient />, document.getElementById("app")!);
|
||||||
|
|||||||
163
src/lib/performance-tracking.ts
Normal file
163
src/lib/performance-tracking.ts
Normal file
@@ -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 = {};
|
||||||
|
}
|
||||||
@@ -30,13 +30,13 @@ interface PerformanceTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PERFORMANCE_TARGETS: Record<string, PerformanceTarget> = {
|
const PERFORMANCE_TARGETS: Record<string, PerformanceTarget> = {
|
||||||
lcp: { good: 2500, acceptable: 4000, label: "LCP", unit: "ms" },
|
lcp: { good: 1500, acceptable: 2500, label: "LCP", unit: "ms" },
|
||||||
fcp: { good: 1800, acceptable: 3000, label: "FCP", unit: "ms" },
|
fcp: { good: 1000, acceptable: 1800, label: "FCP", unit: "ms" },
|
||||||
ttfb: { good: 800, acceptable: 1800, label: "TTFB", unit: "ms" },
|
ttfb: { good: 500, acceptable: 800, label: "TTFB", unit: "ms" },
|
||||||
cls: { good: 0.1, acceptable: 0.25, label: "CLS", unit: "" },
|
cls: { good: 0.05, acceptable: 0.1, label: "CLS", unit: "" },
|
||||||
avgDuration: {
|
avgDuration: {
|
||||||
good: 3000,
|
good: 2000,
|
||||||
acceptable: 5000,
|
acceptable: 3000,
|
||||||
label: "Avg Duration",
|
label: "Avg Duration",
|
||||||
unit: "ms"
|
unit: "ms"
|
||||||
}
|
}
|
||||||
@@ -57,22 +57,22 @@ function getPerformanceRating(
|
|||||||
function getRatingColor(rating: "good" | "acceptable" | "poor"): string {
|
function getRatingColor(rating: "good" | "acceptable" | "poor"): string {
|
||||||
switch (rating) {
|
switch (rating) {
|
||||||
case "good":
|
case "good":
|
||||||
return "text-green-600 dark:text-green-400";
|
return "text-green";
|
||||||
case "acceptable":
|
case "acceptable":
|
||||||
return "text-yellow-600 dark:text-yellow-400";
|
return "text-yellow";
|
||||||
case "poor":
|
case "poor":
|
||||||
return "text-red-600 dark:text-red-400";
|
return "text-red";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRatingBgColor(rating: "good" | "acceptable" | "poor"): string {
|
function getRatingBgColor(rating: "good" | "acceptable" | "poor"): string {
|
||||||
switch (rating) {
|
switch (rating) {
|
||||||
case "good":
|
case "good":
|
||||||
return "bg-green-100 dark:bg-green-900/30";
|
return "bg-green/10";
|
||||||
case "acceptable":
|
case "acceptable":
|
||||||
return "bg-yellow-100 dark:bg-yellow-900/30";
|
return "bg-yellow/10";
|
||||||
case "poor":
|
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() {
|
export default function AnalyticsPage() {
|
||||||
const adminCheck = createAsync(() => checkAdmin());
|
|
||||||
|
|
||||||
const [timeWindow, setTimeWindow] = createSignal(7);
|
const [timeWindow, setTimeWindow] = createSignal(7);
|
||||||
const [selectedPath, setSelectedPath] = createSignal<string | null>(null);
|
const [selectedPath, setSelectedPath] = createSignal<string | null>(null);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(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 pathStats = createAsync(async () => {
|
||||||
const path = selectedPath();
|
const path = selectedPath();
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
@@ -120,13 +129,13 @@ export default function AnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>Analytics Dashboard - Admin</Title>
|
<Title>Analytics Dashboard - Admin</Title>
|
||||||
<div class="min-h-screen bg-gray-50 px-4 py-8 dark:bg-gray-900">
|
<div class="bg-base min-h-screen px-4 py-8">
|
||||||
<div class="mx-auto max-w-7xl">
|
<div class="mx-auto max-w-7xl">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="mb-2 text-4xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 class="text-text mb-2 text-4xl font-bold">
|
||||||
Analytics Dashboard
|
Analytics Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
<p class="text-subtext0">
|
||||||
Visitor analytics and performance metrics
|
Visitor analytics and performance metrics
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,8 +148,8 @@ export default function AnalyticsPage() {
|
|||||||
onClick={() => setTimeWindow(days)}
|
onClick={() => setTimeWindow(days)}
|
||||||
class={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
class={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||||
timeWindow() === days
|
timeWindow() === days
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue text-base"
|
||||||
: "bg-white text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
: "bg-surface0 text-text hover:bg-surface1 border-surface1 border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{days === 1 ? "24h" : `${days}d`}
|
{days === 1 ? "24h" : `${days}d`}
|
||||||
@@ -150,7 +159,7 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<div class="mb-6 rounded-lg bg-red-100 p-4 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
<div class="bg-red/20 border-red text-red mb-6 rounded-lg border p-4">
|
||||||
<p class="font-semibold">Error loading analytics</p>
|
<p class="font-semibold">Error loading analytics</p>
|
||||||
<p class="text-sm">{error()}</p>
|
<p class="text-sm">{error()}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,53 +170,218 @@ export default function AnalyticsPage() {
|
|||||||
<>
|
<>
|
||||||
{/* Overview Cards */}
|
{/* Overview Cards */}
|
||||||
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 rounded-lg border p-6 shadow">
|
||||||
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-subtext0 mb-1 text-sm">Total Requests</div>
|
||||||
Total Requests
|
<div class="text-text text-3xl font-bold">
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
{formatNumber(data().totalVisits)}
|
{formatNumber(data().totalVisits)}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-subtext1 mt-1 text-xs">
|
||||||
{formatNumber(data().totalPageVisits)} pages,{" "}
|
{formatNumber(data().totalPageVisits)} pages,{" "}
|
||||||
{formatNumber(data().totalApiCalls)} API
|
{formatNumber(data().totalApiCalls)} API
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 rounded-lg border p-6 shadow">
|
||||||
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-subtext0 mb-1 text-sm">
|
||||||
Unique Visitors
|
Unique Visitors
|
||||||
</div>
|
</div>
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div class="text-text text-3xl font-bold">
|
||||||
{formatNumber(data().uniqueVisitors)}
|
{formatNumber(data().uniqueVisitors)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 rounded-lg border p-6 shadow">
|
||||||
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-subtext0 mb-1 text-sm">
|
||||||
Authenticated Users
|
Authenticated Users
|
||||||
</div>
|
</div>
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div class="text-text text-3xl font-bold">
|
||||||
{formatNumber(data().uniqueUsers)}
|
{formatNumber(data().uniqueUsers)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg bg-white p-6 shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 rounded-lg border p-6 shadow">
|
||||||
<div class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-subtext0 mb-1 text-sm">
|
||||||
Avg. Visits/Day
|
Avg. Visits/Day
|
||||||
</div>
|
</div>
|
||||||
<div class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
<div class="text-text text-3xl font-bold">
|
||||||
{formatNumber(data().totalVisits / timeWindow())}
|
{formatNumber(data().totalVisits / timeWindow())}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Pages */}
|
{/* Performance Metrics Section */}
|
||||||
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
<Show
|
||||||
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
when={
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
performanceStats() &&
|
||||||
Top Pages
|
performanceStats()!.totalWithMetrics > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-text mb-4 text-2xl font-bold">
|
||||||
|
Core Web Vitals
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{/* Performance Overview Cards */}
|
||||||
|
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Show when={performanceStats()?.avgLcp != null}>
|
||||||
|
<div
|
||||||
|
class={`border-surface1 rounded-lg border p-6 shadow ${getRatingBgColor(getPerformanceRating("lcp", performanceStats()!.avgLcp!))}`}
|
||||||
|
>
|
||||||
|
<div class="text-subtext0 mb-1 text-sm font-medium">
|
||||||
|
LCP (Largest Contentful Paint)
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={`text-3xl font-bold ${getRatingColor(getPerformanceRating("lcp", performanceStats()!.avgLcp!))}`}
|
||||||
|
>
|
||||||
|
{Math.round(performanceStats()!.avgLcp!)}ms
|
||||||
|
</div>
|
||||||
|
<div class="text-subtext1 mt-1 text-xs">
|
||||||
|
Target: <1.5s (good), <2.5s (ok)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={performanceStats()?.avgFcp != null}>
|
||||||
|
<div
|
||||||
|
class={`border-surface1 rounded-lg border p-6 shadow ${getRatingBgColor(getPerformanceRating("fcp", performanceStats()!.avgFcp!))}`}
|
||||||
|
>
|
||||||
|
<div class="text-subtext0 mb-1 text-sm font-medium">
|
||||||
|
FCP (First Contentful Paint)
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={`text-3xl font-bold ${getRatingColor(getPerformanceRating("fcp", performanceStats()!.avgFcp!))}`}
|
||||||
|
>
|
||||||
|
{Math.round(performanceStats()!.avgFcp!)}ms
|
||||||
|
</div>
|
||||||
|
<div class="text-subtext1 mt-1 text-xs">
|
||||||
|
Target: <1s (good), <1.8s (ok)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={performanceStats()?.avgCls != null}>
|
||||||
|
<div
|
||||||
|
class={`border-surface1 rounded-lg border p-6 shadow ${getRatingBgColor(getPerformanceRating("cls", performanceStats()!.avgCls!))}`}
|
||||||
|
>
|
||||||
|
<div class="text-subtext0 mb-1 text-sm font-medium">
|
||||||
|
CLS (Cumulative Layout Shift)
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={`text-3xl font-bold ${getRatingColor(getPerformanceRating("cls", performanceStats()!.avgCls!))}`}
|
||||||
|
>
|
||||||
|
{performanceStats()!.avgCls!.toFixed(3)}
|
||||||
|
</div>
|
||||||
|
<div class="text-subtext1 mt-1 text-xs">
|
||||||
|
Target: <0.05 (good), <0.1 (ok)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={performanceStats()?.avgTtfb != null}>
|
||||||
|
<div
|
||||||
|
class={`border-surface1 rounded-lg border p-6 shadow ${getRatingBgColor(getPerformanceRating("ttfb", performanceStats()!.avgTtfb!))}`}
|
||||||
|
>
|
||||||
|
<div class="text-subtext0 mb-1 text-sm font-medium">
|
||||||
|
TTFB (Time to First Byte)
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={`text-3xl font-bold ${getRatingColor(getPerformanceRating("ttfb", performanceStats()!.avgTtfb!))}`}
|
||||||
|
>
|
||||||
|
{Math.round(performanceStats()!.avgTtfb!)}ms
|
||||||
|
</div>
|
||||||
|
<div class="text-subtext1 mt-1 text-xs">
|
||||||
|
Target: <500ms (good), <800ms (ok)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance by Page */}
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
performanceStats()?.byPath &&
|
||||||
|
performanceStats()!.byPath.length > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
|
||||||
|
<div class="border-surface1 border-b p-6">
|
||||||
|
<h3 class="text-text text-xl font-bold">
|
||||||
|
Performance by Page
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 mt-1 text-sm">
|
||||||
|
{performanceStats()!.totalWithMetrics} page loads
|
||||||
|
with performance data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="border-surface1 border-b">
|
||||||
|
<tr class="text-subtext0 text-left">
|
||||||
|
<th class="pr-4 pb-3 font-medium">Page</th>
|
||||||
|
<th class="pr-4 pb-3 text-right font-medium">
|
||||||
|
LCP
|
||||||
|
</th>
|
||||||
|
<th class="pr-4 pb-3 text-right font-medium">
|
||||||
|
FCP
|
||||||
|
</th>
|
||||||
|
<th class="pr-4 pb-3 text-right font-medium">
|
||||||
|
CLS
|
||||||
|
</th>
|
||||||
|
<th class="pr-4 pb-3 text-right font-medium">
|
||||||
|
TTFB
|
||||||
|
</th>
|
||||||
|
<th class="pb-3 text-right font-medium">
|
||||||
|
Samples
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={performanceStats()!.byPath || []}>
|
||||||
|
{(page) => (
|
||||||
|
<tr class="border-surface1 border-b">
|
||||||
|
<td class="text-text py-3 pr-4 font-mono text-xs">
|
||||||
|
{page.path}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class={`py-3 pr-4 text-right font-medium ${getRatingColor(getPerformanceRating("lcp", page.avgLcp))}`}
|
||||||
|
>
|
||||||
|
{Math.round(page.avgLcp)}ms
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class={`py-3 pr-4 text-right font-medium ${getRatingColor(getPerformanceRating("fcp", page.avgFcp))}`}
|
||||||
|
>
|
||||||
|
{Math.round(page.avgFcp)}ms
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class={`py-3 pr-4 text-right font-medium ${getRatingColor(getPerformanceRating("cls", page.avgCls))}`}
|
||||||
|
>
|
||||||
|
{page.avgCls.toFixed(3)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class={`py-3 pr-4 text-right font-medium ${getRatingColor(getPerformanceRating("ttfb", page.avgTtfb))}`}
|
||||||
|
>
|
||||||
|
{Math.round(page.avgTtfb)}ms
|
||||||
|
</td>
|
||||||
|
<td class="text-subtext0 py-3 text-right">
|
||||||
|
{page.count}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Top Pages */}
|
||||||
|
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
|
||||||
|
<div class="border-surface1 border-b p-6">
|
||||||
|
<h2 class="text-text text-2xl font-bold">Top Pages</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -217,24 +391,24 @@ export default function AnalyticsPage() {
|
|||||||
(pathData.count / data().totalPageVisits) * 100;
|
(pathData.count / data().totalPageVisits) * 100;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer rounded-lg p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
|
class="hover:bg-surface1 cursor-pointer rounded-lg p-3 transition-colors"
|
||||||
onClick={() => setSelectedPath(pathData.path)}
|
onClick={() => setSelectedPath(pathData.path)}
|
||||||
>
|
>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="font-mono text-sm text-gray-900 dark:text-gray-100">
|
<span class="text-text font-mono text-sm">
|
||||||
{pathData.path}
|
{pathData.path}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span class="text-text text-sm font-semibold">
|
||||||
{formatNumber(pathData.count)} visits
|
{formatNumber(pathData.count)} visits
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="bg-surface1 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full bg-blue-600"
|
class="h-2 rounded-full bg-blue-600"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-subtext1 mt-1 text-xs">
|
||||||
{percentage.toFixed(1)}% of page traffic
|
{percentage.toFixed(1)}% of page traffic
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,11 +420,9 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top API Calls */}
|
{/* Top API Calls */}
|
||||||
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
|
||||||
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
<div class="border-surface1 border-b p-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 class="text-text text-2xl font-bold">Top API Calls</h2>
|
||||||
Top API Calls
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -261,20 +433,20 @@ export default function AnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<div class="rounded-lg p-3">
|
<div class="rounded-lg p-3">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="font-mono text-xs break-all text-gray-900 dark:text-gray-100">
|
<span class="text-text font-mono text-xs break-all">
|
||||||
{apiData.path}
|
{apiData.path}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-4 text-sm font-semibold whitespace-nowrap text-gray-900 dark:text-gray-100">
|
<span class="text-text ml-4 text-sm font-semibold whitespace-nowrap">
|
||||||
{formatNumber(apiData.count)}
|
{formatNumber(apiData.count)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="bg-surface1 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full bg-purple-600"
|
class="h-2 rounded-full bg-purple-600"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-subtext1 mt-1 text-xs">
|
||||||
{percentage.toFixed(1)}% of API traffic
|
{percentage.toFixed(1)}% of API traffic
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,11 +460,9 @@ export default function AnalyticsPage() {
|
|||||||
{/* Device & Browser Stats */}
|
{/* Device & Browser Stats */}
|
||||||
<div class="mb-8 grid grid-cols-1 gap-8 md:grid-cols-2">
|
<div class="mb-8 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
{/* Device Types */}
|
{/* Device Types */}
|
||||||
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
|
||||||
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
<div class="border-surface1 border-b p-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 class="text-text text-2xl font-bold">Device Types</h2>
|
||||||
Device Types
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -312,12 +482,12 @@ export default function AnalyticsPage() {
|
|||||||
<span class="text-sm font-medium text-gray-700 capitalize dark:text-gray-300">
|
<span class="text-sm font-medium text-gray-700 capitalize dark:text-gray-300">
|
||||||
{device.type}
|
{device.type}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
<span class="text-subtext0 text-sm">
|
||||||
{formatNumber(device.count)} (
|
{formatNumber(device.count)} (
|
||||||
{percentage.toFixed(1)}%)
|
{percentage.toFixed(1)}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="bg-surface1 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full bg-purple-600"
|
class="h-2 rounded-full bg-purple-600"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
@@ -332,11 +502,9 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Browsers */}
|
{/* Browsers */}
|
||||||
<div class="rounded-lg bg-white shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 rounded-lg border shadow">
|
||||||
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
<div class="border-surface1 border-b p-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 class="text-text text-2xl font-bold">Browsers</h2>
|
||||||
Browsers
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -356,12 +524,12 @@ export default function AnalyticsPage() {
|
|||||||
<span class="text-sm font-medium text-gray-700 capitalize dark:text-gray-300">
|
<span class="text-sm font-medium text-gray-700 capitalize dark:text-gray-300">
|
||||||
{browser.browser}
|
{browser.browser}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
<span class="text-subtext0 text-sm">
|
||||||
{formatNumber(browser.count)} (
|
{formatNumber(browser.count)} (
|
||||||
{percentage.toFixed(1)}%)
|
{percentage.toFixed(1)}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="bg-surface1 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full bg-green-600"
|
class="h-2 rounded-full bg-green-600"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
@@ -378,9 +546,9 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
{/* Top Referrers */}
|
{/* Top Referrers */}
|
||||||
<Show when={data().topReferrers.length > 0}>
|
<Show when={data().topReferrers.length > 0}>
|
||||||
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
|
||||||
<div class="border-b border-gray-200 p-6 dark:border-gray-700">
|
<div class="border-surface1 border-b p-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 class="text-text text-2xl font-bold">
|
||||||
Top Referrers
|
Top Referrers
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,11 +556,11 @@ export default function AnalyticsPage() {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<For each={data().topReferrers}>
|
<For each={data().topReferrers}>
|
||||||
{(referrer) => (
|
{(referrer) => (
|
||||||
<div class="flex justify-between border-b border-gray-100 py-2 dark:border-gray-700">
|
<div class="border-surface1 flex justify-between border-b py-2">
|
||||||
<span class="max-w-md truncate text-sm text-gray-700 dark:text-gray-300">
|
<span class="text-text max-w-md truncate text-sm">
|
||||||
{referrer.referrer}
|
{referrer.referrer}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span class="text-text text-sm font-semibold">
|
||||||
{formatNumber(referrer.count)}
|
{formatNumber(referrer.count)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,14 +577,14 @@ export default function AnalyticsPage() {
|
|||||||
{/* Path Details Modal/Section */}
|
{/* Path Details Modal/Section */}
|
||||||
<Show when={selectedPath() && pathStats()}>
|
<Show when={selectedPath() && pathStats()}>
|
||||||
{(stats) => (
|
{(stats) => (
|
||||||
<div class="mb-8 rounded-lg bg-white shadow dark:bg-gray-800">
|
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
|
||||||
<div class="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
|
<div class="border-surface1 flex items-center justify-between border-b p-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h2 class="text-text text-2xl font-bold">
|
||||||
Path Details: {selectedPath()}
|
Path Details: {selectedPath()}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedPath(null)}
|
onClick={() => setSelectedPath(null)}
|
||||||
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
class="text-subtext0 hover:text-text"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -424,26 +592,20 @@ export default function AnalyticsPage() {
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-subtext0 text-sm">Total Visits</div>
|
||||||
Total Visits
|
<div class="text-text text-2xl font-bold">
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
{formatNumber(stats().totalVisits)}
|
{formatNumber(stats().totalVisits)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-subtext0 text-sm">Unique Visitors</div>
|
||||||
Unique Visitors
|
<div class="text-text text-2xl font-bold">
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
{formatNumber(stats().uniqueVisitors)}
|
{formatNumber(stats().uniqueVisitors)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
<div class="text-subtext0 text-sm">Avg. Duration</div>
|
||||||
Avg. Duration
|
<div class="text-text text-2xl font-bold">
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
||||||
{stats().avgDurationMs
|
{stats().avgDurationMs
|
||||||
? `${(stats().avgDurationMs! / 1000).toFixed(1)}s`
|
? `${(stats().avgDurationMs! / 1000).toFixed(1)}s`
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
@@ -454,7 +616,7 @@ export default function AnalyticsPage() {
|
|||||||
{/* Visits by Day */}
|
{/* Visits by Day */}
|
||||||
<Show when={stats().visitsByDay.length > 0}>
|
<Show when={stats().visitsByDay.length > 0}>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h3 class="text-text mb-4 text-lg font-semibold">
|
||||||
Visits by Day
|
Visits by Day
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -467,14 +629,14 @@ export default function AnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex justify-between">
|
<div class="mb-1 flex justify-between">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
<span class="text-text text-sm">
|
||||||
{new Date(day.date).toLocaleDateString()}
|
{new Date(day.date).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span class="text-text text-sm font-semibold">
|
||||||
{formatNumber(day.count)}
|
{formatNumber(day.count)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<div class="bg-surface1 h-2 w-full rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full bg-blue-600"
|
class="h-2 rounded-full bg-blue-600"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
|
|||||||
@@ -815,6 +815,26 @@ button:active,
|
|||||||
color: var(--color-base);
|
color: var(--color-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Video styles for blog posts */
|
||||||
|
#post-content-body video,
|
||||||
|
#post-content-body .iframe-wrapper video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
display: block;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||||
|
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#post-content-body .iframe-wrapper {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Editor styles remain unchanged */
|
/* Editor styles remain unchanged */
|
||||||
pre {
|
pre {
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export interface AnalyticsEntry {
|
|||||||
os?: string | null;
|
os?: string | null;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
durationMs?: number | 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> {
|
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
|
||||||
@@ -23,8 +31,9 @@ export async function logVisit(entry: AnalyticsEntry): Promise<void> {
|
|||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `INSERT INTO VisitorAnalytics (
|
sql: `INSERT INTO VisitorAnalytics (
|
||||||
id, user_id, path, method, referrer, user_agent, ip_address,
|
id, user_id, path, method, referrer, user_agent, ip_address,
|
||||||
country, device_type, browser, os, session_id, duration_ms
|
country, device_type, browser, os, session_id, duration_ms,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
args: [
|
args: [
|
||||||
uuid(),
|
uuid(),
|
||||||
entry.userId || null,
|
entry.userId || null,
|
||||||
@@ -38,7 +47,15 @@ export async function logVisit(entry: AnalyticsEntry): Promise<void> {
|
|||||||
entry.browser || null,
|
entry.browser || null,
|
||||||
entry.os || null,
|
entry.os || null,
|
||||||
entry.sessionId || 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) {
|
} 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(
|
export async function cleanupOldAnalytics(
|
||||||
olderThanDays: number
|
olderThanDays: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
|||||||
@@ -1,13 +1,147 @@
|
|||||||
import { createTRPCRouter, adminProcedure } from "../utils";
|
import { createTRPCRouter, adminProcedure, publicProcedure } from "../utils";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
queryAnalytics,
|
queryAnalytics,
|
||||||
getAnalyticsSummary,
|
getAnalyticsSummary,
|
||||||
getPathAnalytics,
|
getPathAnalytics,
|
||||||
cleanupOldAnalytics
|
cleanupOldAnalytics,
|
||||||
|
logVisit,
|
||||||
|
getPerformanceStats
|
||||||
} from "~/server/analytics";
|
} from "~/server/analytics";
|
||||||
|
import { ConnectionFactory } from "~/server/database";
|
||||||
|
|
||||||
export const analyticsRouter = createTRPCRouter({
|
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
|
getLogs: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -82,5 +216,20 @@ export const analyticsRouter = createTRPCRouter({
|
|||||||
deleted,
|
deleted,
|
||||||
olderThanDays: input.olderThanDays
|
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,6 +50,8 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
const ipAddress = getRequestIP(event.nativeEvent) || undefined;
|
||||||
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
|
const sessionId = getCookie(event.nativeEvent, "session_id") || undefined;
|
||||||
|
|
||||||
|
// Don't log the performance logging endpoint itself to avoid circular tracking
|
||||||
|
if (!path.includes("analytics.logPerformance")) {
|
||||||
logVisit(
|
logVisit(
|
||||||
enrichAnalyticsEntry({
|
enrichAnalyticsEntry({
|
||||||
userId,
|
userId,
|
||||||
@@ -61,6 +63,7 @@ async function createContextInner(event: APIEvent): Promise<Context> {
|
|||||||
sessionId
|
sessionId
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
|
|||||||
Reference in New Issue
Block a user