import type { AnalyticsEventProperties, AnalyticsService, } from "./analytics-service"; import { config } from "../../config"; /** * Configuration options for UmamiAnalyticsService. * * @property enabled - Whether analytics are enabled */ export type UmamiAnalyticsServiceOptions = { enabled: boolean; }; /** * Umami Analytics Service Implementation (Script-less/Proxy edition). * * This version implements the Umami tracking protocol directly via fetch, * eliminating the need to load an external script.js file. * * In the browser, it gathers standard metadata (screen, language, referrer) * and sends it to the proxied '/stats/api/send' endpoint. */ export class UmamiAnalyticsService implements AnalyticsService { private websiteId?: string; private endpoint: string; private serverContext?: { userAgent?: string; language?: string; referrer?: string; ip?: string; }; constructor(private readonly options: UmamiAnalyticsServiceOptions) { this.websiteId = config.analytics.umami.websiteId; // On server, use the full internal URL; on client, use the proxied path this.endpoint = typeof window === "undefined" ? config.analytics.umami.apiEndpoint : "/stats"; } /** * Set the server-side context for the current request. * This allows the service to use real request headers for tracking. */ setServerContext(context: { userAgent?: string; language?: string; referrer?: string; ip?: string; }) { this.serverContext = context; } /** * Internal method to send the payload to Umami API. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private async sendPayload(type: "event", data: Record) { if (!this.options.enabled || !this.websiteId) return; try { const payload = { website: this.websiteId, hostname: typeof window !== "undefined" ? window.location.hostname : this.serverContext?.referrer ? new URL(this.serverContext.referrer).hostname : "server", screen: typeof window !== "undefined" ? `${window.screen.width}x${window.screen.height}` : undefined, language: typeof window !== "undefined" ? navigator.language : this.serverContext?.language, referrer: typeof window !== "undefined" ? document.referrer : this.serverContext?.referrer, ...data, }; const headers: Record = { "Content-Type": "application/json", }; // Set User-Agent if (typeof window !== "undefined") { headers["User-Agent"] = navigator.userAgent; } else if (this.serverContext?.userAgent) { headers["User-Agent"] = this.serverContext.userAgent; } else { headers["User-Agent"] = "Mintel-Server-Proxy"; } // Forward client IP if available (Umami must be configured to trust this) if (this.serverContext?.ip) { headers["X-Forwarded-For"] = this.serverContext.ip; } const response = await fetch(`${this.endpoint}/api/send`, { method: "POST", headers, body: JSON.stringify({ type, payload }), keepalive: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); if (!response.ok && process.env.NODE_ENV === "development") { const errorText = await response.text(); console.warn( `[Umami] API responded with ${response.status}: ${errorText}`, ); } } catch (error) { if (process.env.NODE_ENV === "development") { console.error("[Umami] Failed to send analytics:", error); } } } /** * Track a custom event. */ track(eventName: string, props?: AnalyticsEventProperties) { this.sendPayload("event", { name: eventName, data: props, url: typeof window !== "undefined" ? window.location.pathname + window.location.search : this.serverContext?.referrer ? new URL(this.serverContext.referrer).pathname : "/", }); } /** * Track a pageview. */ trackPageview(url?: string) { this.sendPayload("event", { url: url || (typeof window !== "undefined" ? window.location.pathname + window.location.search : this.serverContext?.referrer ? new URL(this.serverContext.referrer).pathname : "/"), }); } }