import type { Prisma } from "@prisma/client"; import { prisma } from "../db/prisma.js"; import { logger } from "./logger.service.js"; /** Who caused an {@link AnalyticsEvent}. `"user"` pairs with an `actorId` (`UserProfile.id`); `"system"` is a background job; `"anon"` is an unauthenticated request. */ export type AnalyticsActorType = "user" | "system" | "anon"; /** Optional context for {@link AnalyticsService.recordEvent}. */ export interface RecordEventOptions { /** `UserProfile.id` — set together with `actorType: "user"` (the default when this is present). */ actorId?: number; /** Overrides the inferred actor type (`"user"` when `actorId` is set, else `"anon"`). */ actorType?: AnalyticsActorType; /** Small free-form blob for later slicing (`{ sourceKey, recipeId, … }`) — nothing queries into it today. */ context?: Prisma.InputJsonValue; } /** * Records product usage events for the admin dashboard's metrics (see the * `AnalyticsEvent` model doc comment). A class rather than a bare function * — same convention as `LoggerService`/`ErrorHandlerService`: `public` * `recordEvent` is the API, `_insert` is the internal it fans out to. * * **Fire-and-forget by contract**: `recordEvent` returns `void`, not a * promise. The insert runs detached, and a failure is logged at `warn` and * swallowed — analytics must never add latency to, or fail, the request * that triggered it. Call sites therefore never `await` it. */ export class AnalyticsService { public recordEvent(type: string, options: RecordEventOptions = {}): void { const actorType: AnalyticsActorType = options.actorType ?? (options.actorId !== undefined ? "user" : "anon"); void this._insert(type, actorType, options).catch((err: unknown) => { logger.warn("Analytics event insert failed", { eventType: type, error: err instanceof Error ? err.message : String(err), }); }); } private async _insert( type: string, actorType: AnalyticsActorType, options: RecordEventOptions, ): Promise { try { await prisma.analyticsEvent.create({ data: { type, actorType, actorId: options.actorId ?? null, context: options.context, }, }); } catch (err) { // Rethrown so `recordEvent`'s `.catch` above logs it — this layer // just isn't allowed a bare `await` per the repo's convention. throw err; } } } /** Single shared instance — stateless, same reasoning as `logger`. */ export const analytics = new AnalyticsService();