import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service'; import { config } from '../../config'; import type { LoggerService } from '../logging/logger-service'; /** * 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. * On the server, it sends directly to the internal Umami API. */ export class UmamiAnalyticsService implements AnalyticsService { private websiteId?: string; private endpoint: string; private logger: LoggerService; constructor( private readonly options: UmamiAnalyticsServiceOptions, logger: LoggerService, ) { this.websiteId = config.analytics.umami.websiteId; this.logger = logger.child({ component: 'analytics-umami' }); // On server, use the full internal URL; on client, use the proxied path this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats'; this.logger.debug('Umami service initialized', { enabled: this.options.enabled, websiteId: this.websiteId ? 'configured' : 'not configured (client-side proxy mode)', endpoint: this.endpoint, }); } /** * Internal method to send the payload to Umami API. */ private async sendPayload(type: 'event', data: Record) { if (!this.options.enabled) return; // On the client, we don't need the websiteId (it's injected by the server-side proxy handler). // On the server, we need it because we're calling the Umami API directly. const isClient = typeof window !== 'undefined'; if (!isClient && !this.websiteId) { this.logger.warn('Umami tracking called on server but no Website ID configured'); return; } try { const payload = { website: this.websiteId, hostname: isClient ? window.location.hostname : 'server', screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined, language: isClient ? navigator.language : undefined, referrer: isClient ? document.referrer : undefined, ...data, }; this.logger.trace('Sending analytics payload', { type, url: data.url }); // Add a timeout to prevent hanging requests const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout try { const response = await fetch(`${this.endpoint}/api/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': isClient ? navigator.userAgent : 'KLZ-Server', }, body: JSON.stringify({ type, payload }), keepalive: true, signal: controller.signal, } as any); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); this.logger.warn('Umami API responded with error', { status: response.status, error: errorText.slice(0, 100), }); } } catch (fetchError) { clearTimeout(timeoutId); if ((fetchError as Error).name === 'AbortError') { this.logger.error('Umami request timed out'); } else { throw fetchError; } } } catch (error) { this.logger.error('Failed to send analytics', { error: (error as Error).message, }); } } /** * 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 : undefined, }); } /** * Track a pageview. */ trackPageview(url?: string) { this.sendPayload('event', { url: url || (typeof window !== 'undefined' ? window.location.pathname + window.location.search : undefined), }); } }