because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add AI call log persistence and logging provider decorator

+257
+32
apps/server/prisma/migrations/20260210120000_add_ai_call_log/migration.sql
··· 1 + -- CreateTable 2 + CREATE TABLE "ai_call_logs" ( 3 + "id" TEXT NOT NULL, 4 + "userId" TEXT, 5 + "providerName" TEXT NOT NULL, 6 + "model" TEXT, 7 + "source" TEXT, 8 + "durationMs" INTEGER NOT NULL, 9 + "promptTokens" INTEGER, 10 + "completionTokens" INTEGER, 11 + "finishReason" TEXT, 12 + "status" TEXT NOT NULL, 13 + "error" TEXT, 14 + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 + 16 + CONSTRAINT "ai_call_logs_pkey" PRIMARY KEY ("id") 17 + ); 18 + 19 + -- CreateIndex 20 + CREATE INDEX "ai_call_logs_userId_idx" ON "ai_call_logs"("userId"); 21 + 22 + -- CreateIndex 23 + CREATE INDEX "ai_call_logs_status_idx" ON "ai_call_logs"("status"); 24 + 25 + -- CreateIndex 26 + CREATE INDEX "ai_call_logs_providerName_idx" ON "ai_call_logs"("providerName"); 27 + 28 + -- CreateIndex 29 + CREATE INDEX "ai_call_logs_createdAt_idx" ON "ai_call_logs"("createdAt"); 30 + 31 + -- AddForeignKey 32 + ALTER TABLE "ai_call_logs" ADD CONSTRAINT "ai_call_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+22
apps/server/prisma/models/ai-call-log.prisma
··· 1 + model AiCallLog { 2 + id String @id @default(cuid()) 3 + userId String? 4 + providerName String 5 + model String? 6 + source String? 7 + durationMs Int 8 + promptTokens Int? 9 + completionTokens Int? 10 + finishReason String? 11 + status String 12 + error String? 13 + createdAt DateTime @default(now()) 14 + 15 + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) 16 + 17 + @@index([userId]) 18 + @@index([status]) 19 + @@index([providerName]) 20 + @@index([createdAt]) 21 + @@map("ai_call_logs") 22 + }
+54
apps/server/src/modules/admin/ai-call-log-persistence.service.ts
··· 1 + import { Injectable, Logger } from "@nestjs/common"; 2 + import { PrismaService } from "@/modules/database/prisma.service"; 3 + import type { AiCallLogEntry } from "./ai-call-log.service"; 4 + 5 + interface QueryOptions { 6 + limit?: number; 7 + status?: string; 8 + providerName?: string; 9 + } 10 + 11 + /** 12 + * Persists AI call log entries to the database. 13 + * All writes are fire-and-forget to avoid impacting AI call latency. 14 + */ 15 + @Injectable() 16 + export class AiCallLogPersistenceService { 17 + private readonly logger = new Logger(AiCallLogPersistenceService.name); 18 + 19 + constructor(private readonly prisma: PrismaService) {} 20 + 21 + persist(entry: AiCallLogEntry): void { 22 + this.prisma.aiCallLog 23 + .create({ 24 + data: { 25 + id: entry.id, 26 + userId: entry.userId ?? null, 27 + providerName: entry.providerName, 28 + model: entry.model ?? null, 29 + source: entry.source ?? null, 30 + durationMs: entry.durationMs, 31 + promptTokens: entry.promptTokens ?? null, 32 + completionTokens: entry.completionTokens ?? null, 33 + finishReason: entry.finishReason ?? null, 34 + status: entry.status, 35 + error: entry.error ?? null, 36 + createdAt: new Date(entry.timestamp), 37 + }, 38 + }) 39 + .catch((err) => { 40 + this.logger.error("Failed to persist AI call log entry", err); 41 + }); 42 + } 43 + 44 + async query(options: QueryOptions = {}) { 45 + return this.prisma.aiCallLog.findMany({ 46 + where: { 47 + ...(options.status ? { status: options.status } : {}), 48 + ...(options.providerName ? { providerName: options.providerName } : {}), 49 + }, 50 + orderBy: { createdAt: "desc" }, 51 + take: options.limit ?? 100, 52 + }); 53 + } 54 + }
+12
apps/server/src/modules/admin/ai-call-log.module.ts
··· 1 + import { Global, Module } from "@nestjs/common"; 2 + import { DatabaseModule } from "@/modules/database/database.module"; 3 + import { AiCallLogPersistenceService } from "./ai-call-log-persistence.service"; 4 + import { AiCallLogService } from "./ai-call-log.service"; 5 + 6 + @Global() 7 + @Module({ 8 + imports: [DatabaseModule], 9 + providers: [AiCallLogPersistenceService, AiCallLogService], 10 + exports: [AiCallLogService], 11 + }) 12 + export class AiCallLogModule {}
+54
apps/server/src/modules/admin/ai-call-log.service.ts
··· 1 + import { Injectable, Optional } from "@nestjs/common"; 2 + import { AiCallLogPersistenceService } from "./ai-call-log-persistence.service"; 3 + 4 + export interface AiCallLogEntry { 5 + id: string; 6 + timestamp: string; 7 + providerName: string; 8 + durationMs: number; 9 + promptTokens?: number | undefined; 10 + completionTokens?: number | undefined; 11 + model?: string | undefined; 12 + finishReason?: string | undefined; 13 + status: "success" | "error"; 14 + error?: string | undefined; 15 + userId?: string | undefined; 16 + source?: string | undefined; 17 + } 18 + 19 + const MAX_ENTRIES = 100; 20 + 21 + /** 22 + * In-memory ring buffer for AI provider call history. 23 + * Retains the last 100 entries; resets on server restart. 24 + * Optionally persists to DB via AiCallLogPersistenceService. 25 + */ 26 + @Injectable() 27 + export class AiCallLogService { 28 + private readonly entries: AiCallLogEntry[] = []; 29 + 30 + constructor( 31 + @Optional() 32 + private readonly persistenceService?: AiCallLogPersistenceService, 33 + ) {} 34 + 35 + record(entry: AiCallLogEntry): void { 36 + this.entries.unshift(entry); 37 + if (this.entries.length > MAX_ENTRIES) { 38 + this.entries.length = MAX_ENTRIES; 39 + } 40 + this.persistenceService?.persist(entry); 41 + } 42 + 43 + getEntries(limit?: number): AiCallLogEntry[] { 44 + return limit ? this.entries.slice(0, limit) : [...this.entries]; 45 + } 46 + 47 + async queryHistory(options?: { 48 + limit?: number; 49 + status?: string; 50 + providerName?: string; 51 + }) { 52 + return this.persistenceService?.query(options) ?? []; 53 + } 54 + }
+83
apps/server/src/modules/admin/logging-ai-provider.ts
··· 1 + import { randomUUID } from "node:crypto"; 2 + import type { 3 + AICompletionRequest, 4 + AICompletionResponse, 5 + AIProvider, 6 + AIProviderStatus, 7 + } from "@cv/ai-provider"; 8 + import { AiCallLogService } from "./ai-call-log.service"; 9 + 10 + interface LoggingOptions { 11 + userId?: string; 12 + source?: string; 13 + } 14 + 15 + /** 16 + * Thin wrapper around an AIProvider that records call timing and 17 + * response metadata to the in-memory AiCallLogService. 18 + */ 19 + export class LoggingAIProvider implements AIProvider { 20 + readonly name: string; 21 + 22 + constructor( 23 + private readonly inner: AIProvider, 24 + private readonly logService: AiCallLogService, 25 + private readonly options: LoggingOptions = {}, 26 + ) { 27 + this.name = inner.name; 28 + } 29 + 30 + async complete(request: AICompletionRequest): Promise<AICompletionResponse> { 31 + const start = performance.now(); 32 + 33 + try { 34 + const response = await this.inner.complete(request); 35 + const durationMs = Math.round(performance.now() - start); 36 + 37 + this.logService.record({ 38 + id: randomUUID(), 39 + timestamp: new Date().toISOString(), 40 + providerName: this.name, 41 + durationMs, 42 + promptTokens: response.promptTokens, 43 + completionTokens: response.completionTokens, 44 + model: response.model, 45 + finishReason: response.finishReason, 46 + status: "success", 47 + userId: this.options.userId, 48 + source: this.options.source, 49 + }); 50 + 51 + return response; 52 + } catch (err) { 53 + const durationMs = Math.round(performance.now() - start); 54 + 55 + this.logService.record({ 56 + id: randomUUID(), 57 + timestamp: new Date().toISOString(), 58 + providerName: this.name, 59 + durationMs, 60 + status: "error", 61 + error: err instanceof Error ? err.message : String(err), 62 + userId: this.options.userId, 63 + source: this.options.source, 64 + }); 65 + 66 + throw err; 67 + } 68 + } 69 + 70 + isHealthy(): Promise<boolean> { 71 + return this.inner.isHealthy(); 72 + } 73 + 74 + getStatus(): Promise<AIProviderStatus> { 75 + return ( 76 + this.inner.getStatus?.() ?? 77 + Promise.resolve({ 78 + healthy: false, 79 + providerName: this.name, 80 + }) 81 + ); 82 + } 83 + }