feat(analytics): record pageviews server-side only
Umami never received data: the client-side path (browser beacon to /stats/api/send) has been dead in production. Pageviews are now classified and recorded entirely on the server in the request proxy; no tracking script ships to the browser anymore. - Pure domain decision (decidePageview) filters real page views from assets, infrastructure paths, prefetches and bot user agents - Pure payload builder maps facts onto the Umami send contract as an unnamed event, with the client identity travelling in headers - proxy.ts wires request facts into the domain and fires the send without delaying the response; configured via UMAMI_WEBSITE_ID and UMAMI_API_ENDPOINT - Removes the dead client analytics stack (components, adapters, /stats proxy route) and its layout mount
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||
import { Analytics } from "@/src/components/Analytics";
|
||||
import { BitBurst } from "@/src/components/bits/BitBurst";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
@@ -72,7 +71,6 @@ export default function RootLayout({
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitBurst />
|
||||
<Analytics />
|
||||
</body>
|
||||
</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,55 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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+$/, "");
|
||||
|
||||
const isPrefetchRequest = (request: NextRequest): boolean =>
|
||||
request.headers.get("next-router-prefetch") === "1" ||
|
||||
request.headers.get("purpose") === "prefetch" ||
|
||||
request.headers.get("sec-purpose") === "prefetch";
|
||||
|
||||
export default function proxy(request: NextRequest): NextResponse {
|
||||
const decision = decidePageview({
|
||||
method: request.method,
|
||||
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")),
|
||||
isRscNavigation: request.headers.get("rsc") === "1",
|
||||
isPrefetch: isPrefetchRequest(request),
|
||||
});
|
||||
|
||||
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,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decidePageview,
|
||||
type IncomingPageviewRequest,
|
||||
} from "./pageview-decision";
|
||||
|
||||
const humanRequest = (
|
||||
overrides: Partial<IncomingPageviewRequest> = {},
|
||||
): IncomingPageviewRequest => ({
|
||||
method: "GET",
|
||||
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",
|
||||
isRscNavigation: false,
|
||||
isPrefetch: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("decidePageview", () => {
|
||||
it("tracks a document request 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("tracks a client-side route change (RSC navigation without prefetch)", () => {
|
||||
const decision = decidePageview(humanRequest({ isRscNavigation: true }));
|
||||
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("skips a router prefetch", () => {
|
||||
const decision = decidePageview(humanRequest({ isPrefetch: true }));
|
||||
expect(decision).toEqual({ track: false, reason: "prefetch" });
|
||||
});
|
||||
|
||||
it("skips a prefetch even when the agent is a bot", () => {
|
||||
const decision = decidePageview(
|
||||
humanRequest({ isPrefetch: true, userAgent: "curl/8.1.2" }),
|
||||
);
|
||||
expect(decision).toEqual({ track: false, reason: "prefetch" });
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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 (src/proxy.ts).
|
||||
*/
|
||||
|
||||
export type IncomingPageviewRequest = {
|
||||
readonly method: string;
|
||||
readonly pathname: string;
|
||||
readonly search: string;
|
||||
readonly hostname: string;
|
||||
readonly referrer: string;
|
||||
readonly language: string;
|
||||
readonly userAgent: string;
|
||||
readonly clientIp: string;
|
||||
readonly isRscNavigation: boolean;
|
||||
readonly isPrefetch: boolean;
|
||||
};
|
||||
|
||||
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"
|
||||
| "prefetch"
|
||||
| "static-asset"
|
||||
| "infrastructure-path"
|
||||
| "bot-user-agent";
|
||||
|
||||
export type PageviewDecision =
|
||||
| { readonly track: true; readonly facts: PageviewFacts }
|
||||
| { readonly track: false; readonly reason: PageviewSkipReason };
|
||||
|
||||
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.isPrefetch) {
|
||||
return { track: false, reason: "prefetch" };
|
||||
}
|
||||
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