From d2ee61b830a12399fbaa2d3536c6c802ee48b0b9 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 6 Jan 2026 00:40:44 -0500 Subject: [PATCH] analytics page --- src/db/types.ts | 26 ++ src/routes/analytics.tsx | 498 ++++++++++++++++++++++++++++ src/routes/blog/create/index.tsx | 3 +- src/routes/blog/edit/[id].tsx | 3 +- src/server/analytics.ts | 369 +++++++++++++++++++++ src/server/api/root.ts | 2 + src/server/api/routers/analytics.ts | 86 +++++ src/server/api/utils.ts | 29 ++ 8 files changed, 1012 insertions(+), 4 deletions(-) create mode 100644 src/routes/analytics.tsx create mode 100644 src/server/analytics.ts create mode 100644 src/server/api/routers/analytics.ts diff --git a/src/db/types.ts b/src/db/types.ts index b76ff76..08dacac 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -135,3 +135,29 @@ export interface PostWithTags { tags: Tag[]; last_edited_date?: string | null; } + +export interface VisitorAnalytics { + id: string; + user_id?: string | null; + path: string; + method: string; + referrer?: string | null; + user_agent?: string | null; + ip_address?: string | null; + country?: string | null; + device_type?: string | null; + browser?: string | null; + os?: string | null; + session_id?: string | null; + duration_ms?: number | null; + created_at: string; +} + +export interface AnalyticsQuery { + userId?: string; + path?: string; + startDate?: Date | string; + endDate?: Date | string; + limit?: number; + offset?: number; +} diff --git a/src/routes/analytics.tsx b/src/routes/analytics.tsx new file mode 100644 index 0000000..8d019a4 --- /dev/null +++ b/src/routes/analytics.tsx @@ -0,0 +1,498 @@ +import { createSignal, Show, For, createEffect, ErrorBoundary } from "solid-js"; +import { Title } from "@solidjs/meta"; +import { redirect, query, createAsync, useNavigate } from "@solidjs/router"; +import { getEvent } from "vinxi/http"; +import { api } from "~/lib/api"; + +const checkAdmin = query(async (): Promise => { + "use server"; + const { getUserID } = await import("~/server/auth"); + const { env } = await import("~/env/server"); + const event = getEvent()!; + const userId = await getUserID(event); + + if (!userId || userId !== env.ADMIN_ID) { + throw redirect("/"); + } + + return true; +}, "checkAdminAccess"); + +export const route = { + load: () => checkAdmin() +}; + +interface PerformanceTarget { + good: number; + acceptable: number; + label: string; + unit: string; +} + +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: "" }, + avgDuration: { + good: 3000, + acceptable: 5000, + label: "Avg Duration", + unit: "ms" + } +}; + +function getPerformanceRating( + metric: string, + value: number +): "good" | "acceptable" | "poor" { + const target = PERFORMANCE_TARGETS[metric]; + if (!target) return "acceptable"; + + if (value <= target.good) return "good"; + if (value <= target.acceptable) return "acceptable"; + return "poor"; +} + +function getRatingColor(rating: "good" | "acceptable" | "poor"): string { + switch (rating) { + case "good": + return "text-green-600 dark:text-green-400"; + case "acceptable": + return "text-yellow-600 dark:text-yellow-400"; + case "poor": + return "text-red-600 dark:text-red-400"; + } +} + +function getRatingBgColor(rating: "good" | "acceptable" | "poor"): string { + switch (rating) { + case "good": + return "bg-green-100 dark:bg-green-900/30"; + case "acceptable": + return "bg-yellow-100 dark:bg-yellow-900/30"; + case "poor": + return "bg-red-100 dark:bg-red-900/30"; + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes.toFixed(0)}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)}MB`; +} + +function formatNumber(num: number): string { + return new Intl.NumberFormat().format(Math.round(num)); +} + +export default function AnalyticsPage() { + const adminCheck = createAsync(() => checkAdmin()); + + const [timeWindow, setTimeWindow] = createSignal(7); + const [selectedPath, setSelectedPath] = createSignal(null); + const [error, setError] = createSignal(null); + + const summary = createAsync(async () => { + try { + setError(null); + return await api.analytics.getSummary.query({ days: timeWindow() }); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load analytics"); + return null; + } + }); + + const pathStats = createAsync(async () => { + const path = selectedPath(); + if (!path) return null; + try { + return await api.analytics.getPathStats.query({ + path, + days: timeWindow() + }); + } catch (e) { + console.error("Failed to load path stats:", e); + return null; + } + }); + + return ( + <> + Analytics Dashboard - Admin +
+
+
+

+ Analytics Dashboard +

+

+ Visitor analytics and performance metrics +

+
+ + {/* Time Window Selector */} +
+ + {(days) => ( + + )} + +
+ + +
+

Error loading analytics

+

{error()}

+
+
+ + + {(data) => ( + <> + {/* Overview Cards */} +
+
+
+ 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 +

+
+
+
+ + {(pathData) => { + const percentage = + (pathData.count / data().totalPageVisits) * 100; + return ( +
setSelectedPath(pathData.path)} + > +
+ + {pathData.path} + + + {formatNumber(pathData.count)} visits + +
+
+
+
+
+ {percentage.toFixed(1)}% of page traffic +
+
+ ); + }} + +
+
+
+ + {/* Top API Calls */} +
+
+

+ Top API Calls +

+
+
+
+ + {(apiData) => { + const percentage = + (apiData.count / data().totalApiCalls) * 100; + return ( +
+
+ + {apiData.path} + + + {formatNumber(apiData.count)} + +
+
+
+
+
+ {percentage.toFixed(1)}% of API traffic +
+
+ ); + }} + +
+
+
+ + {/* Device & Browser Stats */} +
+ {/* Device Types */} +
+
+

+ Device Types +

+
+
+
+ + {(device) => { + const totalDevices = data().deviceTypes.reduce( + (sum, d) => sum + d.count, + 0 + ); + const percentage = + totalDevices > 0 + ? (device.count / totalDevices) * 100 + : 0; + return ( +
+
+ + {device.type} + + + {formatNumber(device.count)} ( + {percentage.toFixed(1)}%) + +
+
+
+
+
+ ); + }} + +
+
+
+ + {/* Browsers */} +
+
+

+ Browsers +

+
+
+
+ + {(browser) => { + const totalBrowsers = data().browsers.reduce( + (sum, b) => sum + b.count, + 0 + ); + const percentage = + totalBrowsers > 0 + ? (browser.count / totalBrowsers) * 100 + : 0; + return ( +
+
+ + {browser.browser} + + + {formatNumber(browser.count)} ( + {percentage.toFixed(1)}%) + +
+
+
+
+
+ ); + }} + +
+
+
+
+ + {/* Top Referrers */} + 0}> +
+
+

+ Top Referrers +

+
+
+
+ + {(referrer) => ( +
+ + {referrer.referrer} + + + {formatNumber(referrer.count)} + +
+ )} +
+
+
+
+
+ + )} + + + {/* Path Details Modal/Section */} + + {(stats) => ( +
+
+

+ Path Details: {selectedPath()} +

+ +
+
+
+
+
+ Total Visits +
+
+ {formatNumber(stats().totalVisits)} +
+
+
+
+ Unique Visitors +
+
+ {formatNumber(stats().uniqueVisitors)} +
+
+
+
+ Avg. Duration +
+
+ {stats().avgDurationMs + ? `${(stats().avgDurationMs! / 1000).toFixed(1)}s` + : "N/A"} +
+
+
+ + {/* Visits by Day */} + 0}> +
+

+ Visits by Day +

+
+ + {(day) => { + const maxVisits = Math.max( + ...stats().visitsByDay.map((d) => d.count) + ); + const percentage = (day.count / maxVisits) * 100; + return ( +
+
+ + {new Date(day.date).toLocaleDateString()} + + + {formatNumber(day.count)} + +
+
+
+
+
+ ); + }} + +
+
+ +
+
+ )} + +
+
+ + ); +} diff --git a/src/routes/blog/create/index.tsx b/src/routes/blog/create/index.tsx index 9573fe1..2a7115c 100644 --- a/src/routes/blog/create/index.tsx +++ b/src/routes/blog/create/index.tsx @@ -15,9 +15,8 @@ const getAuthState = query(async () => { const privilegeLevel = await getPrivilegeLevel(event); const userID = await getUserID(event); - // Return 401 for non-admin users if (privilegeLevel !== "admin") { - throw new Response("Unauthorized", { status: 401 }); + throw redirect("/401"); } return { privilegeLevel, userID }; diff --git a/src/routes/blog/edit/[id].tsx b/src/routes/blog/edit/[id].tsx index 5b49195..01832ff 100644 --- a/src/routes/blog/edit/[id].tsx +++ b/src/routes/blog/edit/[id].tsx @@ -15,9 +15,8 @@ const getPostForEdit = query(async (id: string) => { const privilegeLevel = await getPrivilegeLevel(event); const userID = await getUserID(event); - // Return 401 for non-admin users if (privilegeLevel !== "admin") { - throw new Response("Unauthorized", { status: 401 }); + throw redirect("/401"); } const conn = ConnectionFactory(); diff --git a/src/server/analytics.ts b/src/server/analytics.ts new file mode 100644 index 0000000..9ede91e --- /dev/null +++ b/src/server/analytics.ts @@ -0,0 +1,369 @@ +import { ConnectionFactory } from "./database"; +import { v4 as uuid } from "uuid"; +import type { VisitorAnalytics, AnalyticsQuery } from "~/db/types"; + +export interface AnalyticsEntry { + userId?: string | null; + path: string; + method: string; + referrer?: string | null; + userAgent?: string | null; + ipAddress?: string | null; + country?: string | null; + deviceType?: string | null; + browser?: string | null; + os?: string | null; + sessionId?: string | null; + durationMs?: number | null; +} + +export async function logVisit(entry: AnalyticsEntry): Promise { + try { + const conn = ConnectionFactory(); + await conn.execute({ + sql: `INSERT INTO VisitorAnalytics ( + id, user_id, path, method, referrer, user_agent, ip_address, + country, device_type, browser, os, session_id, duration_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + uuid(), + entry.userId || null, + entry.path, + entry.method, + entry.referrer || null, + entry.userAgent || null, + entry.ipAddress || null, + entry.country || null, + entry.deviceType || null, + entry.browser || null, + entry.os || null, + entry.sessionId || null, + entry.durationMs || null + ] + }); + } catch (error) { + console.error("Failed to log visitor analytics:", error, entry); + } +} + +export async function queryAnalytics( + query: AnalyticsQuery +): Promise { + const conn = ConnectionFactory(); + + let sql = "SELECT * FROM VisitorAnalytics WHERE 1=1"; + const args: any[] = []; + + if (query.userId) { + sql += " AND user_id = ?"; + args.push(query.userId); + } + + if (query.path) { + sql += " AND path = ?"; + args.push(query.path); + } + + if (query.startDate) { + sql += " AND created_at >= ?"; + args.push( + typeof query.startDate === "string" + ? query.startDate + : query.startDate.toISOString() + ); + } + + if (query.endDate) { + sql += " AND created_at <= ?"; + args.push( + typeof query.endDate === "string" + ? query.endDate + : query.endDate.toISOString() + ); + } + + sql += " ORDER BY created_at DESC"; + + if (query.limit) { + sql += " LIMIT ?"; + args.push(query.limit); + } + + if (query.offset) { + sql += " OFFSET ?"; + args.push(query.offset); + } + + const result = await conn.execute({ sql, args }); + return result.rows.map((row) => ({ + id: row.id as string, + user_id: row.user_id as string | null, + path: row.path as string, + method: row.method as string, + referrer: row.referrer as string | null, + user_agent: row.user_agent as string | null, + ip_address: row.ip_address as string | null, + country: row.country as string | null, + device_type: row.device_type as string | null, + browser: row.browser as string | null, + os: row.os as string | null, + session_id: row.session_id as string | null, + duration_ms: row.duration_ms as number | null, + created_at: row.created_at as string + })); +} + +export async function getAnalyticsSummary(days: number = 30): Promise<{ + totalVisits: number; + totalPageVisits: number; + totalApiCalls: number; + uniqueVisitors: number; + uniqueUsers: number; + topPages: Array<{ path: string; count: number }>; + topApiCalls: Array<{ path: string; count: number }>; + topReferrers: Array<{ referrer: string; count: number }>; + deviceTypes: Array<{ type: string; count: number }>; + browsers: Array<{ browser: string; count: number }>; +}> { + const conn = ConnectionFactory(); + + const totalVisitsResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM VisitorAnalytics + WHERE created_at >= datetime('now', '-${days} days')`, + args: [] + }); + const totalVisits = (totalVisitsResult.rows[0]?.count as number) || 0; + + const totalPageVisitsResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM VisitorAnalytics + WHERE created_at >= datetime('now', '-${days} days') + AND path NOT LIKE '/api/%'`, + args: [] + }); + const totalPageVisits = (totalPageVisitsResult.rows[0]?.count as number) || 0; + + const totalApiCallsResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM VisitorAnalytics + WHERE created_at >= datetime('now', '-${days} days') + AND path LIKE '/api/%'`, + args: [] + }); + const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0; + + const uniqueVisitorsResult = await conn.execute({ + sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics + WHERE ip_address IS NOT NULL + AND created_at >= datetime('now', '-${days} days')`, + args: [] + }); + const uniqueVisitors = (uniqueVisitorsResult.rows[0]?.count as number) || 0; + + const uniqueUsersResult = await conn.execute({ + sql: `SELECT COUNT(DISTINCT user_id) as count FROM VisitorAnalytics + WHERE user_id IS NOT NULL + AND created_at >= datetime('now', '-${days} days')`, + args: [] + }); + const uniqueUsers = (uniqueUsersResult.rows[0]?.count as number) || 0; + + const topPagesResult = await conn.execute({ + sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics + WHERE created_at >= datetime('now', '-${days} days') + AND path NOT LIKE '/api/%' + GROUP BY path + ORDER BY count DESC + LIMIT 10`, + args: [] + }); + const topPages = topPagesResult.rows.map((row) => ({ + path: row.path as string, + count: row.count as number + })); + + const topApiCallsResult = await conn.execute({ + sql: `SELECT path, COUNT(*) as count FROM VisitorAnalytics + WHERE created_at >= datetime('now', '-${days} days') + AND path LIKE '/api/%' + GROUP BY path + ORDER BY count DESC + LIMIT 10`, + args: [] + }); + const topApiCalls = topApiCallsResult.rows.map((row) => ({ + path: row.path as string, + count: row.count as number + })); + + const topReferrersResult = await conn.execute({ + sql: `SELECT referrer, COUNT(*) as count FROM VisitorAnalytics + WHERE referrer IS NOT NULL + AND created_at >= datetime('now', '-${days} days') + GROUP BY referrer + ORDER BY count DESC + LIMIT 10`, + args: [] + }); + const topReferrers = topReferrersResult.rows.map((row) => ({ + referrer: row.referrer as string, + count: row.count as number + })); + + const deviceTypesResult = await conn.execute({ + sql: `SELECT device_type, COUNT(*) as count FROM VisitorAnalytics + WHERE device_type IS NOT NULL + AND created_at >= datetime('now', '-${days} days') + GROUP BY device_type + ORDER BY count DESC`, + args: [] + }); + const deviceTypes = deviceTypesResult.rows.map((row) => ({ + type: row.device_type as string, + count: row.count as number + })); + + const browsersResult = await conn.execute({ + sql: `SELECT browser, COUNT(*) as count FROM VisitorAnalytics + WHERE browser IS NOT NULL + AND created_at >= datetime('now', '-${days} days') + GROUP BY browser + ORDER BY count DESC + LIMIT 10`, + args: [] + }); + const browsers = browsersResult.rows.map((row) => ({ + browser: row.browser as string, + count: row.count as number + })); + + return { + totalVisits, + totalPageVisits, + totalApiCalls, + uniqueVisitors, + uniqueUsers, + topPages, + topApiCalls, + topReferrers, + deviceTypes, + browsers + }; +} + +export async function getPathAnalytics( + path: string, + days: number = 30 +): Promise<{ + totalVisits: number; + uniqueVisitors: number; + avgDurationMs: number | null; + visitsByDay: Array<{ date: string; count: number }>; +}> { + const conn = ConnectionFactory(); + + const totalVisitsResult = await conn.execute({ + sql: `SELECT COUNT(*) as count FROM VisitorAnalytics + WHERE path = ? + AND created_at >= datetime('now', '-${days} days')`, + args: [path] + }); + const totalVisits = (totalVisitsResult.rows[0]?.count as number) || 0; + + const uniqueVisitorsResult = await conn.execute({ + sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics + WHERE path = ? + AND ip_address IS NOT NULL + AND created_at >= datetime('now', '-${days} days')`, + args: [path] + }); + const uniqueVisitors = (uniqueVisitorsResult.rows[0]?.count as number) || 0; + + const avgDurationResult = await conn.execute({ + sql: `SELECT AVG(duration_ms) as avg FROM VisitorAnalytics + WHERE path = ? + AND duration_ms IS NOT NULL + AND created_at >= datetime('now', '-${days} days')`, + args: [path] + }); + const avgDurationMs = avgDurationResult.rows[0]?.avg as number | null; + + const visitsByDayResult = await conn.execute({ + sql: `SELECT DATE(created_at) as date, COUNT(*) as count + FROM VisitorAnalytics + WHERE path = ? + AND created_at >= datetime('now', '-${days} days') + GROUP BY DATE(created_at) + ORDER BY date DESC`, + args: [path] + }); + const visitsByDay = visitsByDayResult.rows.map((row) => ({ + date: row.date as string, + count: row.count as number + })); + + return { + totalVisits, + uniqueVisitors, + avgDurationMs, + visitsByDay + }; +} + +export async function cleanupOldAnalytics( + olderThanDays: number +): Promise { + const conn = ConnectionFactory(); + + const result = await conn.execute({ + sql: `DELETE FROM VisitorAnalytics + WHERE created_at < datetime('now', '-${olderThanDays} days') + RETURNING id`, + args: [] + }); + + return result.rows.length; +} + +function parseUserAgent(userAgent?: string): { + deviceType: string | null; + browser: string | null; + os: string | null; +} { + if (!userAgent) { + return { deviceType: null, browser: null, os: null }; + } + + const ua = userAgent.toLowerCase(); + + let deviceType: string | null = "desktop"; + if (ua.includes("mobile")) deviceType = "mobile"; + else if (ua.includes("tablet") || ua.includes("ipad")) deviceType = "tablet"; + + let browser: string | null = null; + if (ua.includes("edg")) browser = "edge"; + else if (ua.includes("chrome")) browser = "chrome"; + else if (ua.includes("firefox")) browser = "firefox"; + else if (ua.includes("safari") && !ua.includes("chrome")) browser = "safari"; + else if (ua.includes("opera") || ua.includes("opr")) browser = "opera"; + + let os: string | null = null; + if (ua.includes("windows")) os = "windows"; + else if (ua.includes("mac")) os = "macos"; + else if (ua.includes("linux")) os = "linux"; + else if (ua.includes("android")) os = "android"; + else if (ua.includes("iphone") || ua.includes("ipad")) os = "ios"; + + return { deviceType, browser, os }; +} + +export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry { + const { deviceType, browser, os } = parseUserAgent( + entry.userAgent || undefined + ); + + return { + ...entry, + deviceType: entry.deviceType || deviceType, + browser: entry.browser || browser, + os: entry.os || os + }; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 029e305..8192d6b 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,5 +1,6 @@ import { authRouter } from "./routers/auth"; import { auditRouter } from "./routers/audit"; +import { analyticsRouter } from "./routers/analytics"; import { databaseRouter } from "./routers/database"; import { lineageRouter } from "./routers/lineage"; import { miscRouter } from "./routers/misc"; @@ -13,6 +14,7 @@ import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ auth: authRouter, audit: auditRouter, + analytics: analyticsRouter, database: databaseRouter, lineage: lineageRouter, misc: miscRouter, diff --git a/src/server/api/routers/analytics.ts b/src/server/api/routers/analytics.ts new file mode 100644 index 0000000..d8a04e8 --- /dev/null +++ b/src/server/api/routers/analytics.ts @@ -0,0 +1,86 @@ +import { createTRPCRouter, adminProcedure } from "../utils"; +import { z } from "zod"; +import { + queryAnalytics, + getAnalyticsSummary, + getPathAnalytics, + cleanupOldAnalytics +} from "~/server/analytics"; + +export const analyticsRouter = createTRPCRouter({ + getLogs: adminProcedure + .input( + z.object({ + userId: z.string().optional(), + path: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + limit: z.number().min(1).max(1000).default(100), + offset: z.number().min(0).default(0) + }) + ) + .query(async ({ input }) => { + const logs = await queryAnalytics({ + userId: input.userId, + path: input.path, + startDate: input.startDate, + endDate: input.endDate, + limit: input.limit, + offset: input.offset + }); + + return { + logs, + count: logs.length, + offset: input.offset, + limit: input.limit + }; + }), + + getSummary: adminProcedure + .input( + z.object({ + days: z.number().min(1).max(365).default(30) + }) + ) + .query(async ({ input }) => { + const summary = await getAnalyticsSummary(input.days); + + return { + ...summary, + timeWindow: `${input.days} days` + }; + }), + + getPathStats: adminProcedure + .input( + z.object({ + path: z.string(), + days: z.number().min(1).max(365).default(30) + }) + ) + .query(async ({ input }) => { + const stats = await getPathAnalytics(input.path, input.days); + + return { + path: input.path, + ...stats, + timeWindow: `${input.days} days` + }; + }), + + cleanup: adminProcedure + .input( + z.object({ + olderThanDays: z.number().min(1).max(365).default(90) + }) + ) + .mutation(async ({ input }) => { + const deleted = await cleanupOldAnalytics(input.olderThanDays); + + return { + deleted, + olderThanDays: input.olderThanDays + }; + }) +}); diff --git a/src/server/api/utils.ts b/src/server/api/utils.ts index 687cf99..11c940e 100644 --- a/src/server/api/utils.ts +++ b/src/server/api/utils.ts @@ -3,6 +3,8 @@ import type { APIEvent } from "@solidjs/start/server"; import { getCookie, setCookie } from "vinxi/http"; import { jwtVerify, type JWTPayload } from "jose"; import { env } from "~/env/server"; +import { logVisit, enrichAnalyticsEntry } from "~/server/analytics"; +import { getRequestIP } from "vinxi/http"; export type Context = { event: APIEvent; @@ -33,6 +35,33 @@ async function createContextInner(event: APIEvent): Promise { } } + const req = event.nativeEvent.node?.req || event.nativeEvent; + const path = req.url || event.request?.url || "unknown"; + const method = req.method || event.request?.method || "GET"; + const userAgent = + req.headers?.["user-agent"] || + event.request?.headers?.get("user-agent") || + undefined; + const referrer = + req.headers?.referer || + req.headers?.referrer || + event.request?.headers?.get("referer") || + undefined; + const ipAddress = getRequestIP(event.nativeEvent) || undefined; + const sessionId = getCookie(event.nativeEvent, "session_id") || undefined; + + logVisit( + enrichAnalyticsEntry({ + userId, + path, + method, + userAgent, + referrer, + ipAddress, + sessionId + }) + ); + return { event, userId,