From 42295c3c41cc10a4ebe27ebda2e0e6255a66b8c4 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 9 Feb 2026 23:36:05 +0100 Subject: [PATCH] feat: improved analytics --- app/[locale]/layout.tsx | 14 +++++ app/api/contact/route.ts | 19 +++++++ .../analytics/umami-analytics-service.ts | 52 ++++++++++++++++--- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 266c475..1e7725c 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -114,6 +114,20 @@ export default async function RootLayout({ await import("@/lib/services/create-services.server") ).getServerAppServices(); + // Populate analytics context with headers for high-fidelity server-side tracking + const { headers } = await import("next/headers"); + const requestHeaders = await headers(); + + if ("setServerContext" in serverServices.analytics) { + (serverServices.analytics as any).setServerContext({ + userAgent: requestHeaders.get("user-agent") || undefined, + language: + requestHeaders.get("accept-language")?.split(",")[0] || undefined, + referrer: requestHeaders.get("referer") || undefined, + ip: requestHeaders.get("x-forwarded-for")?.split(",")[0] || undefined, + }); + } + // Track server-side (initial load) serverServices.analytics.trackPageview("/"); diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 7a2df0d..c3f020d 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -8,9 +8,23 @@ export async function POST(req: Request) { const services = getServerAppServices(); const logger = services.logger.child({ action: "contact_submission" }); + // Set analytics context from request headers for high-fidelity server-side tracking + // This fulfills the "server-side via nextjs proxy" requirement + if ("setServerContext" in services.analytics) { + (services.analytics as any).setServerContext({ + userAgent: req.headers.get("user-agent") || undefined, + language: req.headers.get("accept-language")?.split(",")[0] || undefined, + referrer: req.headers.get("referer") || undefined, + ip: req.headers.get("x-forwarded-for")?.split(",")[0] || undefined, + }); + } + try { const { name, email, company, message, website } = await req.json(); + // Track attempt + services.analytics.track("contact-form-attempt"); + // Honeypot check if (website) { logger.info("Spam detected (honeypot)"); @@ -118,6 +132,11 @@ ${message} }); } + // Track success + services.analytics.track("contact-form-success", { + has_company: Boolean(company), + }); + return NextResponse.json({ message: "Ok" }); } catch (error) { logger.error("Global API Error", { error }); diff --git a/lib/services/analytics/umami-analytics-service.ts b/lib/services/analytics/umami-analytics-service.ts index 8807a77..c43d8da 100644 --- a/lib/services/analytics/umami-analytics-service.ts +++ b/lib/services/analytics/umami-analytics-service.ts @@ -25,6 +25,12 @@ export type UmamiAnalyticsServiceOptions = { export class UmamiAnalyticsService implements AnalyticsService { private websiteId?: string; private endpoint: string; + private serverContext?: { + userAgent?: string; + language?: string; + referrer?: string; + ip?: string; + }; constructor(private readonly options: UmamiAnalyticsServiceOptions) { this.websiteId = config.analytics.umami.websiteId; @@ -36,6 +42,19 @@ export class UmamiAnalyticsService implements AnalyticsService { : "/stats"; } + /** + * Set the server-side context for the current request. + * This allows the service to use real request headers for tracking. + */ + setServerContext(context: { + userAgent?: string; + language?: string; + referrer?: string; + ip?: string; + }) { + this.serverContext = context; + } + /** * Internal method to send the payload to Umami API. */ @@ -53,18 +72,37 @@ export class UmamiAnalyticsService implements AnalyticsService { ? `${window.screen.width}x${window.screen.height}` : undefined, language: - typeof window !== "undefined" ? navigator.language : undefined, - referrer: typeof window !== "undefined" ? document.referrer : undefined, + typeof window !== "undefined" + ? navigator.language + : this.serverContext?.language, + referrer: + typeof window !== "undefined" + ? document.referrer + : this.serverContext?.referrer, ...data, }; + const headers: Record = { + "Content-Type": "application/json", + }; + + // Set User-Agent + if (typeof window !== "undefined") { + headers["User-Agent"] = navigator.userAgent; + } else if (this.serverContext?.userAgent) { + headers["User-Agent"] = this.serverContext.userAgent; + } else { + headers["User-Agent"] = "Mintel-Server-Proxy"; + } + + // Forward client IP if available (Umami must be configured to trust this) + if (this.serverContext?.ip) { + headers["X-Forwarded-For"] = this.serverContext.ip; + } + const response = await fetch(`${this.endpoint}/api/send`, { method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": - typeof window === "undefined" ? "KLZ-Server" : navigator.userAgent, - }, + headers, body: JSON.stringify({ type, payload }), keepalive: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any