package logging import ( "log/slog" "time" ) // Имена внешних сервисов для поля ext.service. const ( ServiceQBittorrent = "qbittorrent" ServiceJellyfin = "jellyfin" ServiceLLM = "llm" ServiceTMDB = "tmdb" ServiceTVDB = "tvdb" ServiceTVMaze = "tvmaze" ) // ExtCall описывает один вызов внешнего сервиса для логирования по конвенции // (поля ext.*). Логгер передаётся аргументом — обычно scoped-логгер загрузки, // чтобы запись о вызове несла download_id/capability стадии. type ExtCall struct { Service string // ext.service: qbittorrent/jellyfin/llm/tmdb/tvdb/tvmaze Operation string // ext.operation: логическая операция (torrents/add, chat.completions, search/movie) Start time.Time // начало вызова → duration_ms Status int // ext.status_code: HTTP-код ответа; 0 — не писать (нет кода) Attempt int // номер попытки; >0 — пишем поле retry (поле и метод Retry конфликтовали бы) } // StartCall заводит запись о начинающемся вызове внешнего сервиса, засекая // время. Единая точка отсчёта длительности: клиентам не нужен собственный // time.Now, а конвенция «время генерирует store.Now()» остаётся без исключений // (здесь это не метка времени, а измерение — см. docs/conventions/logging.md). func StartCall(service, operation string) ExtCall { return ExtCall{Service: service, Operation: operation, Start: time.Now()} //nolint:forbidigo // единственная точка отсчёта длительности внешних вызовов } func (c ExtCall) attrs(extra ...any) []any { a := make([]any, 0, 10+len(extra)) a = append(a, "ext.service", c.Service, "ext.operation", c.Operation, "duration_ms", time.Since(c.Start).Milliseconds(), ) if c.Status > 0 { a = append(a, "ext.status_code", c.Status) } if c.Attempt > 0 { a = append(a, "retry", c.Attempt) } return append(a, extra...) } // Success логирует успешный вызов внешнего сервиса (INFO) — одна запись на вызов. func (c ExtCall) Success(log *slog.Logger, extra ...any) { log.Info("external call", c.attrs(extra...)...) } // SuccessDebug — успешный вызов на DEBUG: для частых служебных вызовов // (поллинг qBittorrent и т.п.), которые на INFO забивали бы аудит шумом, как и // healthcheck. Сам факт вызова логируется, но в проде (INFO) не пишется. func (c ExtCall) SuccessDebug(log *slog.Logger, extra ...any) { log.Debug("external call", c.attrs(extra...)...) } // Retry логирует неудачную попытку, после которой будет повтор (WARN). Ошибка // санитизируется (SanitizeErr): транспортный сбой несёт URL с секретом // (api_key TMDB в query, токен в пути) — в лог он попасть не должен. func (c ExtCall) Retry(log *slog.Logger, err error, extra ...any) { log.Warn("external call retry", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...) } // Failure логирует окончательную неудачу вызова / недоступность сервиса (ERROR). // Ошибка санитизируется (SanitizeErr) — см. Retry. func (c ExtCall) Failure(log *slog.Logger, err error, extra ...any) { log.Error("external call failed", c.attrs(append([]any{"error", SanitizeErr(err)}, extra...)...)...) }