PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.
Schema (migration admin_metrics) :
- AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json,
index [type, created_at]) + WorkerHeartbeat (cable en PR 4).
- colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning /
PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de
la migration).
Instrumentation (lib/analytics.service.ts, fire-and-forget) :
- analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert
detache, echec loggue+avale, jamais de latence sur la requete.
- points d'appel : user.signup, recipe.created, recipe.imported,
planning.item_added, tech_step.correction_submitted, shopping_list.viewed.
API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) ->
admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans
base). MetricsView dans packages/shared.
Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes
recettes-par-source / evenements), logique pure dans dashboard.ts, i18n
admin.dashboard.*. AdminApiClient.getMetrics.
reset-db.ts truncate analytics_events + worker_heartbeats.
Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot,
series zero-remplies, event user.signup fire-and-forget) ; Cypress
dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
65 lines
2.5 KiB
TypeScript
65 lines
2.5 KiB
TypeScript
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<void> {
|
|
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();
|