feat(analytics): record pageviews server-side only
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m31s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 5s
Build & Deploy / 🔔 Notify (push) Successful in 2s

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:
2026-10-04 12:24:13 +02:00
parent 390936856c
commit a54df524c7
20 changed files with 417 additions and 821 deletions
-137
View File
@@ -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;
}