package store import ( "context" "database/sql" "log/slog" "textmachine/backend/internal/obs" ) // RequestLog is one telemetry row (Р7: маршрут, токены с cache-полями, $, // latency per книга/глава/чанк/стадия/роль/модель). Telemetry is strictly off // the money path: деньги живут в spend/checkpoints, а сбой записи телеметрии // логируется и никогда не роняет перевод. type RequestLog struct { TraceID string BookID string Chapter int ChunkIdx int Stage string Role string ModelRequested string ModelActual string RequestHash string PromptTokens int CachedTokens int CacheCreationTokens int CompletionTokens int ReasoningTokens int CostUSD float64 LatencyMS int FinishReason string TMHit bool // served from a checkpoint, no call was made Degraded string Err string OK bool } // InsertRequestLog writes one telemetry row synchronously (a CLI pipeline has // no answer-latency to protect, unlike vojo's chat path; the async+recover // pattern returns if this ever sits on a hot path). Errors are the caller's to // log as WARN — never to fail the pipeline on. func (s *Store) InsertRequestLog(rl RequestLog) error { ctx, cancel := opContext() defer cancel() _, err := s.w.ExecContext(ctx, ` INSERT INTO request_log ( trace_id, book_id, chapter, chunk_idx, stage, role, model_requested, model_actual, request_hash, prompt_tokens, cached_tokens, cache_creation_tokens, completion_tokens, reasoning_tokens, cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, rl.TraceID, rl.BookID, rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role, rl.ModelRequested, rl.ModelActual, rl.RequestHash, rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens, rl.CompletionTokens, rl.ReasoningTokens, rl.CostUSD, rl.LatencyMS, rl.FinishReason, boolToInt(rl.TMHit), rl.Degraded, rl.Err, boolToInt(rl.OK)) return err } // LogRequest is the fire-and-forget wrapper the runner uses: insert, WARN on // failure, never propagate. func (s *Store) LogRequest(ctx context.Context, log *slog.Logger, rl RequestLog) { if rl.TraceID == "" { rl.TraceID = obs.TraceFromContext(ctx) } if err := s.InsertRequestLog(rl); err != nil && log != nil { log.WarnContext(ctx, "request_log insert failed (non-fatal)", "err", err) } } // RequestLogRows returns rows for inspection (tmctl report / приёмка Фазы 0). func (s *Store) RequestLogRows(bookID string) (*sql.Rows, error) { ctx, cancel := opContext() defer cancel() return s.r.QueryContext(ctx, ` SELECT ts, stage, role, model_actual, prompt_tokens, cached_tokens, cache_creation_tokens, completion_tokens, reasoning_tokens, cost_usd, latency_ms, finish_reason, tm_hit, ok FROM request_log WHERE book_id = ? ORDER BY id`, bookID) } func boolToInt(b bool) int { if b { return 1 } return 0 }