Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed35413a44 | ||
|
|
a54df524c7 |
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||||
import { Analytics } from "@/src/components/Analytics";
|
|
||||||
import { BitBurst } from "@/src/components/bits/BitBurst";
|
import { BitBurst } from "@/src/components/bits/BitBurst";
|
||||||
import { Footer } from "@/src/components/Footer";
|
import { Footer } from "@/src/components/Footer";
|
||||||
import { Header } from "@/src/components/Header";
|
import { Header } from "@/src/components/Header";
|
||||||
@@ -72,7 +71,6 @@ export default function RootLayout({
|
|||||||
<Footer />
|
<Footer />
|
||||||
<InteractiveElements />
|
<InteractiveElements />
|
||||||
<BitBurst />
|
<BitBurst />
|
||||||
<Analytics />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Smart Proxy for Umami Analytics.
|
|
||||||
*
|
|
||||||
* This Route Handler receives tracking events from the browser,
|
|
||||||
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
|
||||||
* internal Umami API endpoint.
|
|
||||||
*
|
|
||||||
* This ensures:
|
|
||||||
* 1. The Website ID is NOT leaked to the client bundle.
|
|
||||||
* 2. The Umami API endpoint is hidden behind our domain.
|
|
||||||
* 3. We have full control over the tracking data.
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const { type, payload } = body;
|
|
||||||
|
|
||||||
// Inject the secret websiteId from server config
|
|
||||||
const websiteId = env.UMAMI_WEBSITE_ID || env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
|
||||||
|
|
||||||
if (!websiteId) {
|
|
||||||
console.warn(
|
|
||||||
"Umami tracking received but no Website ID configured on server",
|
|
||||||
);
|
|
||||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the enhanced payload with the secret ID
|
|
||||||
const enhancedPayload = {
|
|
||||||
...payload,
|
|
||||||
website: websiteId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const umamiEndpoint = env.UMAMI_API_ENDPOINT;
|
|
||||||
|
|
||||||
// Log the event (debug only)
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.debug("Forwarding analytics event", {
|
|
||||||
type,
|
|
||||||
url: payload.url,
|
|
||||||
website: websiteId.slice(0, 8) + "...",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": request.headers.get("user-agent") || "Mintel-Smart-Proxy",
|
|
||||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error("Umami API responded with error", {
|
|
||||||
status: response.status,
|
|
||||||
error: errorText.slice(0, 100),
|
|
||||||
});
|
|
||||||
return new NextResponse(errorText, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ status: "ok" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to proxy analytics request", {
|
|
||||||
error: (error as Error).message,
|
|
||||||
});
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal Server Error" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Server request shell. Reads the incoming request once, classifies it via
|
||||||
|
* the pure domain decision, and records a page view without touching the
|
||||||
|
* response. All analytics happen server-side; no tracking script ships to
|
||||||
|
* the browser.
|
||||||
|
*
|
||||||
|
* Page views are browser navigations (`sec-fetch-dest: document`).
|
||||||
|
* Router-internal headers (RSC, next-router-prefetch) are stripped by the
|
||||||
|
* server before this shell runs and cannot be used here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { recordPageviewToUmami } from "@/src/analytics/recordPageviewToUmami";
|
||||||
|
import { decidePageview } from "@/src/domain/pageview-decision";
|
||||||
|
|
||||||
|
const primaryLanguage = (acceptLanguage: string | null): string =>
|
||||||
|
acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||||
|
|
||||||
|
const firstForwardedIp = (forwardedFor: string | null): string =>
|
||||||
|
forwardedFor?.split(",")[0]?.trim() ?? "";
|
||||||
|
|
||||||
|
const hostnameWithoutPort = (host: string): string => host.replace(/:\d+$/, "");
|
||||||
|
|
||||||
|
export default function proxy(request: NextRequest): NextResponse {
|
||||||
|
const decision = decidePageview({
|
||||||
|
method: request.method,
|
||||||
|
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
|
||||||
|
pathname: request.nextUrl.pathname,
|
||||||
|
search: request.nextUrl.search,
|
||||||
|
hostname: hostnameWithoutPort(
|
||||||
|
request.headers.get("x-forwarded-host") ??
|
||||||
|
request.headers.get("host") ??
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
referrer: request.headers.get("referer") ?? "",
|
||||||
|
language: primaryLanguage(request.headers.get("accept-language")),
|
||||||
|
userAgent: request.headers.get("user-agent") ?? "",
|
||||||
|
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.track) {
|
||||||
|
recordPageviewToUmami(decision.facts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|.*\\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|mjs|cjs|js|map|txt|xml|json|webmanifest|woff2?|ttf|otf|mp4|webm|mov|pdf|zip)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Imperative shell for page view analytics: sends one page view to the
|
||||||
|
* Umami collection API, identifying the visitor via request headers.
|
||||||
|
* Fire-and-forget by design — analytics must never delay a response.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PageviewFacts } from "@/src/domain/pageview-decision";
|
||||||
|
import { buildUmamiPageviewBody } from "@/src/domain/umami-payload";
|
||||||
|
|
||||||
|
const SEND_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
|
const websiteId = process.env.UMAMI_WEBSITE_ID ?? "";
|
||||||
|
const apiEndpoint = (process.env.UMAMI_API_ENDPOINT ?? "").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
export function recordPageviewToUmami(facts: PageviewFacts): void {
|
||||||
|
if (!websiteId || !apiEndpoint) return;
|
||||||
|
|
||||||
|
const body = buildUmamiPageviewBody(facts, websiteId);
|
||||||
|
|
||||||
|
void fetch(`${apiEndpoint}/api/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": facts.userAgent,
|
||||||
|
"X-Forwarded-For": facts.clientIp,
|
||||||
|
"Accept-Language": facts.language,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
console.error("umami pageview send failed", {
|
||||||
|
url: facts.url,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, Suspense } from "react";
|
|
||||||
import {
|
|
||||||
useSafePathname,
|
|
||||||
useSafeSearchParams,
|
|
||||||
} from "./analytics/useSafePathname";
|
|
||||||
import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker";
|
|
||||||
import { getDefaultAnalytics } from "../utils/analytics";
|
|
||||||
import { getDefaultErrorTracking } from "../utils/error-tracking";
|
|
||||||
|
|
||||||
const AnalyticsInner: React.FC = () => {
|
|
||||||
const pathname = useSafePathname();
|
|
||||||
const searchParams = useSafeSearchParams();
|
|
||||||
|
|
||||||
// Track pageviews on route change
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pathname) return;
|
|
||||||
|
|
||||||
const analytics = getDefaultAnalytics();
|
|
||||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
|
|
||||||
|
|
||||||
analytics.page(url);
|
|
||||||
}, [pathname, searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const analytics = getDefaultAnalytics();
|
|
||||||
const errorTracking = getDefaultErrorTracking();
|
|
||||||
|
|
||||||
// Track page load performance
|
|
||||||
const trackPageLoad = () => {
|
|
||||||
// ... existing implementation ...
|
|
||||||
const perfData = performance.getEntriesByType(
|
|
||||||
"navigation",
|
|
||||||
)[0] as PerformanceNavigationTiming;
|
|
||||||
if (
|
|
||||||
perfData &&
|
|
||||||
typeof perfData.loadEventEnd === "number" &&
|
|
||||||
typeof perfData.startTime === "number"
|
|
||||||
) {
|
|
||||||
const loadTime = perfData.loadEventEnd - perfData.startTime;
|
|
||||||
analytics.trackPageLoad(
|
|
||||||
loadTime,
|
|
||||||
window.location.pathname,
|
|
||||||
navigator.userAgent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track outbound links
|
|
||||||
const trackOutboundLinks = () => {
|
|
||||||
document.querySelectorAll('a[href^="http"]').forEach((link) => {
|
|
||||||
const anchor = link as HTMLAnchorElement;
|
|
||||||
if (!anchor.href.includes(window.location.hostname)) {
|
|
||||||
anchor.addEventListener("click", () => {
|
|
||||||
analytics.trackOutboundLink(
|
|
||||||
anchor.href,
|
|
||||||
anchor.textContent?.trim() || "unknown",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track search
|
|
||||||
const trackSearch = () => {
|
|
||||||
const searchInput = document.querySelector(
|
|
||||||
'input[type="search"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (searchInput) {
|
|
||||||
const handleSearch = (e: Event) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
if (target.value) {
|
|
||||||
analytics.trackSearch(target.value, window.location.pathname);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
searchInput.addEventListener("search", handleSearch);
|
|
||||||
return () => searchInput.removeEventListener("search", handleSearch);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Global error handler for error tracking
|
|
||||||
const handleGlobalError = (event: ErrorEvent) => {
|
|
||||||
errorTracking.captureException(event.error || event.message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
||||||
errorTracking.captureException(event.reason);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("error", handleGlobalError);
|
|
||||||
window.addEventListener("unhandledrejection", handleUnhandledRejection);
|
|
||||||
|
|
||||||
// Initial load tracking
|
|
||||||
if (document.readyState === "complete") {
|
|
||||||
trackPageLoad();
|
|
||||||
trackOutboundLinks();
|
|
||||||
const cleanupSearch = trackSearch();
|
|
||||||
return () => {
|
|
||||||
if (cleanupSearch) cleanupSearch();
|
|
||||||
window.removeEventListener("error", handleGlobalError);
|
|
||||||
window.removeEventListener(
|
|
||||||
"unhandledrejection",
|
|
||||||
handleUnhandledRejection,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
trackPageLoad();
|
|
||||||
trackOutboundLinks();
|
|
||||||
// search tracking might need to wait for hydration/render
|
|
||||||
});
|
|
||||||
// Fallback/standard cleanup
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("error", handleGlobalError);
|
|
||||||
window.removeEventListener(
|
|
||||||
"unhandledrejection",
|
|
||||||
handleUnhandledRejection,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const analytics = getDefaultAnalytics();
|
|
||||||
const adapter = analytics.getAdapter();
|
|
||||||
const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ScrollDepthTracker />
|
|
||||||
{scriptTag && (
|
|
||||||
<div
|
|
||||||
dangerouslySetInnerHTML={{ __html: scriptTag }}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Analytics: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AnalyticsInner />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useSafePathname } from "./useSafePathname";
|
|
||||||
import { useAnalytics } from "./useAnalytics";
|
|
||||||
import { AnalyticsEvents } from "./analytics-events";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ScrollDepthTracker
|
|
||||||
* Tracks user scroll progress across pages.
|
|
||||||
* Fires events at 25%, 50%, 75%, and 100% depth.
|
|
||||||
*/
|
|
||||||
export function ScrollDepthTracker() {
|
|
||||||
const pathname = useSafePathname();
|
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
const trackedDepths = useRef<Set<number>>(new Set());
|
|
||||||
|
|
||||||
// Reset tracking when path changes
|
|
||||||
useEffect(() => {
|
|
||||||
trackedDepths.current.clear();
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
const scrollY = window.scrollY;
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
const documentHeight = document.documentElement.scrollHeight;
|
|
||||||
|
|
||||||
// Calculate how far the user has scrolled in percentage
|
|
||||||
// documentHeight - windowHeight is the total scrollable distance
|
|
||||||
const totalScrollable = documentHeight - windowHeight;
|
|
||||||
if (totalScrollable <= 0) return; // Not scrollable
|
|
||||||
|
|
||||||
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
|
|
||||||
|
|
||||||
// We only care about specific milestones
|
|
||||||
const milestones = [25, 50, 75, 100];
|
|
||||||
|
|
||||||
milestones.forEach((milestone) => {
|
|
||||||
if (
|
|
||||||
scrollPercentage >= milestone &&
|
|
||||||
!trackedDepths.current.has(milestone)
|
|
||||||
) {
|
|
||||||
trackedDepths.current.add(milestone);
|
|
||||||
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
|
|
||||||
depth: milestone,
|
|
||||||
path: pathname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use passive listener for better performance
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
|
|
||||||
// Initial check (in case page is short or already scrolled)
|
|
||||||
if (document.readyState === "complete") {
|
|
||||||
handleScroll();
|
|
||||||
} else {
|
|
||||||
window.addEventListener("load", handleScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", handleScroll);
|
|
||||||
window.removeEventListener("load", handleScroll);
|
|
||||||
};
|
|
||||||
}, [pathname, trackEvent]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Analytics Events Utility
|
|
||||||
*
|
|
||||||
* Centralized definitions for common analytics events and their properties.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const AnalyticsEvents = {
|
|
||||||
// Page & Navigation Events
|
|
||||||
PAGE_VIEW: "pageview",
|
|
||||||
PAGE_SCROLL: "page_scroll",
|
|
||||||
PAGE_EXIT: "page_exit",
|
|
||||||
SCROLL_DEPTH: "scroll_depth",
|
|
||||||
|
|
||||||
// User Interaction Events
|
|
||||||
BUTTON_CLICK: "button_click",
|
|
||||||
LINK_CLICK: "link_click",
|
|
||||||
FORM_SUBMIT: "form_submit",
|
|
||||||
FORM_START: "form_start",
|
|
||||||
FORM_ERROR: "form_error",
|
|
||||||
FORM_FIELD_FOCUS: "form_field_focus",
|
|
||||||
|
|
||||||
// UI Interaction Events
|
|
||||||
MODAL_OPEN: "modal_open",
|
|
||||||
MODAL_CLOSE: "modal_close",
|
|
||||||
TOGGLE_SWITCH: "toggle_switch",
|
|
||||||
ACCORDION_TOGGLE: "accordion_toggle",
|
|
||||||
TAB_SWITCH: "tab_switch",
|
|
||||||
|
|
||||||
// Error & Performance Events
|
|
||||||
ERROR: "error",
|
|
||||||
PERFORMANCE: "performance",
|
|
||||||
API_ERROR: "api_error",
|
|
||||||
API_SUCCESS: "api_success",
|
|
||||||
|
|
||||||
// Custom Business Events
|
|
||||||
CONTACT_FORM_SUBMIT: "contact_form_submit",
|
|
||||||
NEWSLETTER_SUBSCRIBE: "newsletter_subscribe",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type-safe event properties for common events
|
|
||||||
*/
|
|
||||||
export type AnalyticsEventName =
|
|
||||||
(typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { getDefaultAnalytics } from "../../utils/analytics";
|
|
||||||
import type { AnalyticsEventName } from "./analytics-events";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for tracking analytics events.
|
|
||||||
* Wraps the analytics service for easy use in components.
|
|
||||||
*/
|
|
||||||
export function useAnalytics() {
|
|
||||||
const trackEvent = useCallback(
|
|
||||||
(
|
|
||||||
eventName: string | AnalyticsEventName,
|
|
||||||
properties?: Record<string, any>,
|
|
||||||
) => {
|
|
||||||
const analytics = getDefaultAnalytics();
|
|
||||||
analytics.trackEvent(eventName, properties);
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.debug("[Analytics] Tracked event:", eventName, properties);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const trackPageview = useCallback((url?: string) => {
|
|
||||||
const analytics = getDefaultAnalytics();
|
|
||||||
analytics.page(url || window.location.pathname);
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.debug("[Analytics] Tracked pageview:", url ?? "current location");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
trackEvent,
|
|
||||||
trackPageview,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
export function useSafePathname(): string | null {
|
|
||||||
try {
|
|
||||||
return usePathname();
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.warn("Caught usePathname exception (likely Next.js static prerender bug):", error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSafeSearchParams() {
|
|
||||||
try {
|
|
||||||
return useSearchParams();
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.warn("Caught useSearchParams exception (likely Next.js static prerender bug):", error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
decidePageview,
|
||||||
|
type IncomingPageviewRequest,
|
||||||
|
} from "./pageview-decision";
|
||||||
|
|
||||||
|
const humanRequest = (
|
||||||
|
overrides: Partial<IncomingPageviewRequest> = {},
|
||||||
|
): IncomingPageviewRequest => ({
|
||||||
|
method: "GET",
|
||||||
|
secFetchDest: "document",
|
||||||
|
pathname: "/",
|
||||||
|
search: "",
|
||||||
|
hostname: "mintel.me",
|
||||||
|
referrer: "",
|
||||||
|
language: "de-DE",
|
||||||
|
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0",
|
||||||
|
clientIp: "84.117.2.55",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decidePageview", () => {
|
||||||
|
it("tracks a browser navigation for the homepage", () => {
|
||||||
|
const decision = decidePageview(humanRequest());
|
||||||
|
expect(decision).toEqual({
|
||||||
|
track: true,
|
||||||
|
facts: {
|
||||||
|
url: "/",
|
||||||
|
hostname: "mintel.me",
|
||||||
|
referrer: "",
|
||||||
|
language: "de-DE",
|
||||||
|
userAgent: humanRequest().userAgent,
|
||||||
|
clientIp: "84.117.2.55",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the query string in the tracked url", () => {
|
||||||
|
const decision = decidePageview(
|
||||||
|
humanRequest({ pathname: "/imprint", search: "?from=footer" }),
|
||||||
|
);
|
||||||
|
expect(decision).toMatchObject({
|
||||||
|
track: true,
|
||||||
|
facts: { url: "/imprint?from=footer" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks a blog-style slug that merely contains a file-like segment", () => {
|
||||||
|
const decision = decidePageview(
|
||||||
|
humanRequest({ pathname: "/blog/next.js-vs-remix" }),
|
||||||
|
);
|
||||||
|
expect(decision).toMatchObject({ track: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks a slug that merely starts like a metadata image route", () => {
|
||||||
|
const decision = decidePageview(
|
||||||
|
humanRequest({ pathname: "/notes/opengraph-image-deep-dive" }),
|
||||||
|
);
|
||||||
|
expect(decision).toMatchObject({ track: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the method in lower case", () => {
|
||||||
|
const decision = decidePageview(humanRequest({ method: "get" }));
|
||||||
|
expect(decision).toMatchObject({ track: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a form submission", { method: "POST" }],
|
||||||
|
["a head request", { method: "HEAD" }],
|
||||||
|
["an options probe", { method: "OPTIONS" }],
|
||||||
|
])("skips %s", (_label, overrides) => {
|
||||||
|
const decision = decidePageview(humanRequest(overrides));
|
||||||
|
expect(decision).toEqual({ track: false, reason: "non-get-method" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a router prefetch payload", "empty"],
|
||||||
|
["a script subresource", "script"],
|
||||||
|
["an image subresource", "image"],
|
||||||
|
["a request without fetch metadata", ""],
|
||||||
|
])("skips %s", (_label, secFetchDest) => {
|
||||||
|
const decision = decidePageview(humanRequest({ secFetchDest }));
|
||||||
|
expect(decision).toEqual({
|
||||||
|
track: false,
|
||||||
|
reason: "non-navigation-request",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a next build chunk", "/_next/static/chunks/main-abc123.js"],
|
||||||
|
["a next image request", "/_next/image"],
|
||||||
|
["a public image", "/images/portrait.webp"],
|
||||||
|
["a jpeg photo", "/images/photo.jpg"],
|
||||||
|
["a woff font", "/fonts/inter.woff"],
|
||||||
|
["a font", "/fonts/inter.woff2"],
|
||||||
|
["a feed document", "/rss.xml"],
|
||||||
|
["the sitemap", "/sitemap.xml"],
|
||||||
|
["the robots file", "/robots.txt"],
|
||||||
|
["the web manifest", "/manifest.webmanifest"],
|
||||||
|
["an open graph image route", "/opengraph-image"],
|
||||||
|
["a twitter image route", "/twitter-image"],
|
||||||
|
["a favicon", "/favicon.ico"],
|
||||||
|
])("skips %s at %s", (_label, pathname) => {
|
||||||
|
const decision = decidePageview(humanRequest({ pathname }));
|
||||||
|
expect(decision).toEqual({ track: false, reason: "static-asset" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["the analytics proxy", "/stats/api/send"],
|
||||||
|
["the error relay", "/errors/api/relay"],
|
||||||
|
["an api route", "/api/contact"],
|
||||||
|
["the api health check", "/api/health"],
|
||||||
|
["the health check", "/health"],
|
||||||
|
["the gatekeeper", "/gatekeeper/api/verify"],
|
||||||
|
["a well-known challenge", "/.well-known/acme-challenge/token"],
|
||||||
|
])("skips %s at %s", (_label, pathname) => {
|
||||||
|
const decision = decidePageview(humanRequest({ pathname }));
|
||||||
|
expect(decision).toEqual({ track: false, reason: "infrastructure-path" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["curl", "curl/8.1.2"],
|
||||||
|
["wget", "Wget/1.21"],
|
||||||
|
["python requests", "python-requests/2.31"],
|
||||||
|
["go http", "Go-http-client/2.0"],
|
||||||
|
["uptime kuma", "Uptime-Kuma/1.23.11"],
|
||||||
|
["a crawler", "Googlebot/2.1 (+http://www.google.com/bot.html)"],
|
||||||
|
["a link preview", "WhatsApp/2.23.20.0"],
|
||||||
|
])("skips the %s user agent", (_label, userAgent) => {
|
||||||
|
const decision = decidePageview(humanRequest({ userAgent }));
|
||||||
|
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a bot even when its request looks like a navigation", () => {
|
||||||
|
const decision = decidePageview(
|
||||||
|
humanRequest({ userAgent: "curl/8.1.2", secFetchDest: "document" }),
|
||||||
|
);
|
||||||
|
expect(decision).toEqual({ track: false, reason: "bot-user-agent" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Decides whether an incoming HTTP request represents a human page view,
|
||||||
|
* and distils the request into the facts a page view consists of.
|
||||||
|
* Pure classification for use in the request shell (proxy.ts).
|
||||||
|
*
|
||||||
|
* A page view is a browser navigation, identified by the Fetch Metadata
|
||||||
|
* header `sec-fetch-dest: document`. Router-internal headers (RSC,
|
||||||
|
* next-router-prefetch) are stripped by the server before the request
|
||||||
|
* shell runs and can therefore not be used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type IncomingPageviewRequest = {
|
||||||
|
readonly method: string;
|
||||||
|
/** Value of the sec-fetch-dest request header; "" when absent. */
|
||||||
|
readonly secFetchDest: string;
|
||||||
|
readonly pathname: string;
|
||||||
|
readonly search: string;
|
||||||
|
readonly hostname: string;
|
||||||
|
readonly referrer: string;
|
||||||
|
readonly language: string;
|
||||||
|
readonly userAgent: string;
|
||||||
|
readonly clientIp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageviewFacts = {
|
||||||
|
readonly url: string;
|
||||||
|
readonly hostname: string;
|
||||||
|
readonly referrer: string;
|
||||||
|
readonly language: string;
|
||||||
|
readonly userAgent: string;
|
||||||
|
readonly clientIp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageviewSkipReason =
|
||||||
|
| "non-get-method"
|
||||||
|
| "non-navigation-request"
|
||||||
|
| "bot-user-agent"
|
||||||
|
| "static-asset"
|
||||||
|
| "infrastructure-path";
|
||||||
|
|
||||||
|
export type PageviewDecision =
|
||||||
|
| { readonly track: true; readonly facts: PageviewFacts }
|
||||||
|
| { readonly track: false; readonly reason: PageviewSkipReason };
|
||||||
|
|
||||||
|
const NAVIGATION_DESTINATION = "document";
|
||||||
|
|
||||||
|
const NEXT_INTERNAL_PREFIX = "/_next/";
|
||||||
|
|
||||||
|
const STATIC_FILE_PATTERN =
|
||||||
|
/\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|mjs|cjs|js|map|txt|xml|json|webmanifest|woff2?|ttf|otf|eot|mp4|webm|mov|avi|pdf|zip|gz|csv)$/i;
|
||||||
|
|
||||||
|
const METADATA_IMAGE_ROUTES =
|
||||||
|
/\/(?:opengraph-image|twitter-image|apple-icon|icon)$/;
|
||||||
|
|
||||||
|
const INFRASTRUCTURE_PATH_PREFIXES = [
|
||||||
|
"/api/",
|
||||||
|
"/stats/",
|
||||||
|
"/errors/",
|
||||||
|
"/gatekeeper/",
|
||||||
|
"/directus/",
|
||||||
|
"/assets/",
|
||||||
|
"/showcase/",
|
||||||
|
"/.well-known/",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// The only infrastructure path that is neither prefixed nor a static
|
||||||
|
// file extension.
|
||||||
|
const INFRASTRUCTURE_HEALTH_PATH = "/health";
|
||||||
|
|
||||||
|
const BOT_USER_AGENT_PATTERN =
|
||||||
|
/bot|crawl|spider|slurp|curl|wget|python-requests|go-http-client|uptime|whatsapp|facebook|slack|headless/i;
|
||||||
|
|
||||||
|
export function decidePageview(
|
||||||
|
request: IncomingPageviewRequest,
|
||||||
|
): PageviewDecision {
|
||||||
|
if (request.method.toUpperCase() !== "GET") {
|
||||||
|
return { track: false, reason: "non-get-method" };
|
||||||
|
}
|
||||||
|
if (request.secFetchDest !== NAVIGATION_DESTINATION) {
|
||||||
|
return { track: false, reason: "non-navigation-request" };
|
||||||
|
}
|
||||||
|
if (BOT_USER_AGENT_PATTERN.test(request.userAgent)) {
|
||||||
|
return { track: false, reason: "bot-user-agent" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
request.pathname.startsWith(NEXT_INTERNAL_PREFIX) ||
|
||||||
|
STATIC_FILE_PATTERN.test(request.pathname) ||
|
||||||
|
METADATA_IMAGE_ROUTES.test(request.pathname)
|
||||||
|
) {
|
||||||
|
return { track: false, reason: "static-asset" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
request.pathname === INFRASTRUCTURE_HEALTH_PATH ||
|
||||||
|
INFRASTRUCTURE_PATH_PREFIXES.some((prefix) =>
|
||||||
|
request.pathname.startsWith(prefix),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { track: false, reason: "infrastructure-path" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
track: true,
|
||||||
|
facts: {
|
||||||
|
url: `${request.pathname}${request.search}`,
|
||||||
|
hostname: request.hostname,
|
||||||
|
referrer: request.referrer,
|
||||||
|
language: request.language,
|
||||||
|
userAgent: request.userAgent,
|
||||||
|
clientIp: request.clientIp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildUmamiPageviewBody } from "./umami-payload";
|
||||||
|
import type { PageviewFacts } from "./pageview-decision";
|
||||||
|
|
||||||
|
const facts = (): PageviewFacts => ({
|
||||||
|
url: "/work?ref=linkedin",
|
||||||
|
hostname: "mintel.me",
|
||||||
|
referrer: "https://www.google.com/",
|
||||||
|
language: "de-DE",
|
||||||
|
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0",
|
||||||
|
clientIp: "84.117.2.55",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildUmamiPageviewBody", () => {
|
||||||
|
it("maps the facts onto the umami send contract", () => {
|
||||||
|
expect(buildUmamiPageviewBody(facts(), "b903-website")).toEqual({
|
||||||
|
type: "event",
|
||||||
|
payload: {
|
||||||
|
website: "b903-website",
|
||||||
|
hostname: "mintel.me",
|
||||||
|
language: "de-DE",
|
||||||
|
referrer: "https://www.google.com/",
|
||||||
|
url: "/work?ref=linkedin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the pageview as an unnamed event, never as a named custom event", () => {
|
||||||
|
const body = buildUmamiPageviewBody(facts(), "b903-website");
|
||||||
|
expect(body.payload).not.toHaveProperty("name");
|
||||||
|
expect(body.payload).not.toHaveProperty("data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an empty referrer as an empty string", () => {
|
||||||
|
const body = buildUmamiPageviewBody(
|
||||||
|
{ ...facts(), referrer: "" },
|
||||||
|
"b903-website",
|
||||||
|
);
|
||||||
|
expect(body.payload.referrer).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the client identity fields for the sender, outside the payload", () => {
|
||||||
|
const body = buildUmamiPageviewBody(facts(), "b903-website");
|
||||||
|
expect(body).not.toHaveProperty("userAgent");
|
||||||
|
expect(body).not.toHaveProperty("clientIp");
|
||||||
|
expect(body.payload).not.toHaveProperty("userAgent");
|
||||||
|
expect(body.payload).not.toHaveProperty("clientIp");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Maps page view facts onto the Umami /api/send contract.
|
||||||
|
* A page view is an unnamed event: no `name`, no `data`.
|
||||||
|
* The client identity (user agent, IP) travels as request headers, not in
|
||||||
|
* the payload — see src/analytics/recordPageviewToUmami.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PageviewFacts } from "./pageview-decision";
|
||||||
|
|
||||||
|
export type UmamiSendBody = {
|
||||||
|
readonly type: "event";
|
||||||
|
readonly payload: {
|
||||||
|
readonly website: string;
|
||||||
|
readonly hostname: string;
|
||||||
|
readonly language: string;
|
||||||
|
readonly referrer: string;
|
||||||
|
readonly url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildUmamiPageviewBody(
|
||||||
|
facts: PageviewFacts,
|
||||||
|
websiteId: string,
|
||||||
|
): UmamiSendBody {
|
||||||
|
return {
|
||||||
|
type: "event",
|
||||||
|
payload: {
|
||||||
|
website: websiteId,
|
||||||
|
hostname: facts.hostname,
|
||||||
|
language: facts.language,
|
||||||
|
referrer: facts.referrer,
|
||||||
|
url: facts.url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* Analytics Service - Main entry point with DI
|
|
||||||
* Clean constructor-based dependency injection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AnalyticsAdapter,
|
|
||||||
AnalyticsEvent,
|
|
||||||
AnalyticsConfig,
|
|
||||||
} from "./interfaces";
|
|
||||||
import { PlausibleAdapter } from "./plausible-adapter";
|
|
||||||
import { UmamiAdapter, type UmamiConfig } from "./umami-adapter";
|
|
||||||
|
|
||||||
export class AnalyticsService {
|
|
||||||
private adapter: AnalyticsAdapter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create analytics service with dependency injection
|
|
||||||
* @param adapter - Analytics adapter implementation
|
|
||||||
*/
|
|
||||||
constructor(adapter: AnalyticsAdapter) {
|
|
||||||
this.adapter = adapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAdapter(): AnalyticsAdapter {
|
|
||||||
return this.adapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
async track(event: AnalyticsEvent): Promise<void> {
|
|
||||||
return this.adapter.track(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
|
||||||
if (this.adapter.page) {
|
|
||||||
return this.adapter.page(path, props);
|
|
||||||
}
|
|
||||||
return this.track({ name: "Pageview", props: { path, ...props } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
|
|
||||||
if (this.adapter.identify) {
|
|
||||||
return this.adapter.identify(userId, traits);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience methods
|
|
||||||
async trackEvent(name: string, props?: Record<string, any>): Promise<void> {
|
|
||||||
return this.track({ name, props });
|
|
||||||
}
|
|
||||||
|
|
||||||
async trackOutboundLink(url: string, text: string): Promise<void> {
|
|
||||||
return this.track({
|
|
||||||
name: "Outbound Link",
|
|
||||||
props: { url, text },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async trackSearch(query: string, path: string): Promise<void> {
|
|
||||||
return this.track({
|
|
||||||
name: "Search",
|
|
||||||
props: { query, path },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async trackPageLoad(
|
|
||||||
loadTime: number,
|
|
||||||
path: string,
|
|
||||||
userAgent: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return this.track({
|
|
||||||
name: "Page Load",
|
|
||||||
props: { loadTime: Math.round(loadTime), path, userAgent },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory functions
|
|
||||||
export function createPlausibleAnalytics(
|
|
||||||
config: AnalyticsConfig,
|
|
||||||
): AnalyticsService {
|
|
||||||
return new AnalyticsService(new PlausibleAdapter(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createUmamiAnalytics(config: UmamiConfig): AnalyticsService {
|
|
||||||
return new AnalyticsService(new UmamiAdapter(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default singleton
|
|
||||||
let defaultAnalytics: AnalyticsService | null = null;
|
|
||||||
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
|
|
||||||
export function getDefaultAnalytics(): AnalyticsService {
|
|
||||||
if (!defaultAnalytics) {
|
|
||||||
const provider = env.NEXT_PUBLIC_ANALYTICS_PROVIDER;
|
|
||||||
|
|
||||||
if (provider === "umami") {
|
|
||||||
defaultAnalytics = createUmamiAnalytics({
|
|
||||||
hostUrl: env.UMAMI_API_ENDPOINT,
|
|
||||||
});
|
|
||||||
} else if (provider === "plausible") {
|
|
||||||
defaultAnalytics = createPlausibleAnalytics({
|
|
||||||
domain: env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN,
|
|
||||||
scriptUrl: env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No analytics provider configured
|
|
||||||
return {
|
|
||||||
getAdapter: () => ({
|
|
||||||
track: async () => {},
|
|
||||||
page: async () => {},
|
|
||||||
getScriptTag: () => null,
|
|
||||||
}),
|
|
||||||
track: async () => {},
|
|
||||||
page: async () => {},
|
|
||||||
identify: async () => {},
|
|
||||||
trackEvent: async () => {},
|
|
||||||
trackOutboundLink: async () => {},
|
|
||||||
trackSearch: async () => {},
|
|
||||||
trackPageLoad: async () => {},
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultAnalytics;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience function
|
|
||||||
export async function track(
|
|
||||||
name: string,
|
|
||||||
props?: Record<string, any>,
|
|
||||||
): Promise<void> {
|
|
||||||
return getDefaultAnalytics().trackEvent(name, props);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export for advanced usage
|
|
||||||
export type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig };
|
|
||||||
export { PlausibleAdapter };
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Analytics interfaces - decoupled contracts
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface AnalyticsEvent {
|
|
||||||
name: string;
|
|
||||||
props?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalyticsAdapter {
|
|
||||||
track(event: AnalyticsEvent): Promise<void>;
|
|
||||||
identify?(_userId: string, _traits?: Record<string, any>): Promise<void>;
|
|
||||||
page?(_path: string, _props?: Record<string, any>): Promise<void>;
|
|
||||||
getScriptTag?(): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnalyticsConfig {
|
|
||||||
domain?: string;
|
|
||||||
scriptUrl?: string;
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* Plausible Analytics Adapter
|
|
||||||
* Decoupled implementation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AnalyticsAdapter,
|
|
||||||
AnalyticsEvent,
|
|
||||||
AnalyticsConfig,
|
|
||||||
} from "./interfaces";
|
|
||||||
|
|
||||||
export class PlausibleAdapter implements AnalyticsAdapter {
|
|
||||||
private domain: string;
|
|
||||||
private scriptUrl: string;
|
|
||||||
|
|
||||||
constructor(config: AnalyticsConfig) {
|
|
||||||
this.domain = config.domain || "";
|
|
||||||
this.scriptUrl = config.scriptUrl || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async track(event: AnalyticsEvent): Promise<void> {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
|
|
||||||
const w = window as any;
|
|
||||||
if (w.plausible) {
|
|
||||||
w.plausible(event.name, {
|
|
||||||
props: event.props,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
|
||||||
await this.track({
|
|
||||||
name: "Pageview",
|
|
||||||
props: { path, ...props },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getScriptTag(): string {
|
|
||||||
return `<script defer data-domain="${this.domain}" src="${this.scriptUrl}"></script>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Umami Analytics Adapter
|
|
||||||
* Decoupled implementation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AnalyticsAdapter,
|
|
||||||
AnalyticsEvent,
|
|
||||||
AnalyticsConfig,
|
|
||||||
} from "./interfaces";
|
|
||||||
|
|
||||||
export interface UmamiConfig extends AnalyticsConfig {
|
|
||||||
hostUrl?: string; // Optional, defaults to env var on server
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UmamiAdapter implements AnalyticsAdapter {
|
|
||||||
private hostUrl: string;
|
|
||||||
|
|
||||||
constructor(config: UmamiConfig) {
|
|
||||||
this.hostUrl = config.hostUrl || "https://analytics.infra.mintel.me";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendPayload(type: "event", data: Record<string, any>) {
|
|
||||||
try {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
hostname: window.location.hostname,
|
|
||||||
screen: `${window.screen.width}x${window.screen.height}`,
|
|
||||||
language: navigator.language,
|
|
||||||
referrer: document.referrer,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
|
|
||||||
await fetch("/stats/api/send", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type, payload }),
|
|
||||||
keepalive: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to send analytics", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async track(event: AnalyticsEvent): Promise<void> {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
|
|
||||||
await this.sendPayload("event", {
|
|
||||||
name: event.name,
|
|
||||||
data: event.props,
|
|
||||||
url: window.location.pathname + window.location.search,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async page(path: string, props?: Record<string, any>): Promise<void> {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
|
|
||||||
await this.sendPayload("event", {
|
|
||||||
url: path,
|
|
||||||
...props,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async identify(
|
|
||||||
_userId: string,
|
|
||||||
_traits?: Record<string, any>,
|
|
||||||
): Promise<void> {
|
|
||||||
// Not implemented in this version
|
|
||||||
}
|
|
||||||
|
|
||||||
// No script tag needed for proxy mode
|
|
||||||
getScriptTag(): string {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* GlitchTip Error Tracking Adapter
|
|
||||||
* GlitchTip is Sentry-compatible.
|
|
||||||
* This version uses the official @sentry/nextjs SDK.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import type {
|
|
||||||
ErrorTrackingAdapter,
|
|
||||||
ErrorContext,
|
|
||||||
ErrorTrackingConfig,
|
|
||||||
} from "./interfaces";
|
|
||||||
|
|
||||||
export class GlitchTipAdapter implements ErrorTrackingAdapter {
|
|
||||||
constructor(_config: ErrorTrackingConfig) {
|
|
||||||
// Sentry is initialized via sentry.*.config.ts files
|
|
||||||
}
|
|
||||||
|
|
||||||
captureException(error: any, context?: ErrorContext): void {
|
|
||||||
Sentry.captureException(error, {
|
|
||||||
extra: context?.extra,
|
|
||||||
tags: context?.tags,
|
|
||||||
user: context?.user as any,
|
|
||||||
level: context?.level as any,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
captureMessage(message: string, context?: ErrorContext): void {
|
|
||||||
Sentry.captureMessage(message, {
|
|
||||||
extra: context?.extra,
|
|
||||||
tags: context?.tags,
|
|
||||||
user: context?.user as any,
|
|
||||||
level: context?.level as any,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(user: ErrorContext["user"]): void {
|
|
||||||
Sentry.setUser(user as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTag(key: string, value: string): void {
|
|
||||||
Sentry.setTag(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
setExtra(key: string, value: any): void {
|
|
||||||
Sentry.setExtra(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Error Tracking Service - Main entry point with DI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
|
|
||||||
import { GlitchTipAdapter } from './glitchtip-adapter';
|
|
||||||
|
|
||||||
export class ErrorTrackingService {
|
|
||||||
private adapter: ErrorTrackingAdapter;
|
|
||||||
|
|
||||||
constructor(adapter: ErrorTrackingAdapter) {
|
|
||||||
this.adapter = adapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
captureException(error: any, context?: ErrorContext): void {
|
|
||||||
this.adapter.captureException(error, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
captureMessage(message: string, context?: ErrorContext): void {
|
|
||||||
this.adapter.captureMessage(message, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(user: ErrorContext['user']): void {
|
|
||||||
this.adapter.setUser(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTag(key: string, value: string): void {
|
|
||||||
this.adapter.setTag(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
setExtra(key: string, value: any): void {
|
|
||||||
this.adapter.setExtra(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory function
|
|
||||||
export function createGlitchTipErrorTracking(config: ErrorTrackingConfig): ErrorTrackingService {
|
|
||||||
return new ErrorTrackingService(new GlitchTipAdapter(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default singleton
|
|
||||||
let defaultErrorTracking: ErrorTrackingService | null = null;
|
|
||||||
|
|
||||||
export function getDefaultErrorTracking(): ErrorTrackingService {
|
|
||||||
if (!defaultErrorTracking) {
|
|
||||||
defaultErrorTracking = createGlitchTipErrorTracking({
|
|
||||||
dsn: process.env.NEXT_PUBLIC_GLITCHTIP_DSN || '',
|
|
||||||
environment: process.env.NODE_ENV,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return defaultErrorTracking;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience functions
|
|
||||||
export function captureException(error: any, context?: ErrorContext): void {
|
|
||||||
getDefaultErrorTracking().captureException(error, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureMessage(message: string, context?: ErrorContext): void {
|
|
||||||
getDefaultErrorTracking().captureMessage(message, context);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Error Tracking interfaces - decoupled contracts
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ErrorContext {
|
|
||||||
extra?: Record<string, any>;
|
|
||||||
tags?: Record<string, string>;
|
|
||||||
user?: {
|
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
username?: string;
|
|
||||||
};
|
|
||||||
level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorTrackingAdapter {
|
|
||||||
captureException(_error: any, _context?: ErrorContext): void;
|
|
||||||
captureMessage(_message: string, _context?: ErrorContext): void;
|
|
||||||
setUser(_user: ErrorContext['user']): void;
|
|
||||||
setTag(_key: string, _value: string): void;
|
|
||||||
setExtra(_key: string, _value: any): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorTrackingConfig {
|
|
||||||
dsn: string;
|
|
||||||
environment?: string;
|
|
||||||
release?: string;
|
|
||||||
debug?: boolean;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user