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,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