Логирование: доменная граница ошибок + защита секретов в логах

Приём торрента через Telegram молча падал без записи в логах. Разобрали
цепочку и починили логирование/обработку ошибок по конвенции logging.md
(логирует граница домена один раз, транспорты — нет).

Доменная граница логирует исход:
- ingest.Ingest: сбой БД → ERROR, невалидный источник → DEBUG;
- команды воркера (Apply/Cancel/Retry/Refine/…) — единый чокпоинт logCmd
  (ERROR для инфраструктурного сбоя; DEBUG для conflict/not-ready/not-found),
  закрывает и Telegram-, и HTTP-путь; дублирующие ERROR-логи в tgbot сняты;
- внутренний логгер tgbotapi заведён в slog: сбои long-poll getUpdates
  больше не уходят в stdlib log мимо структурированных логов;
- тихое закрытие канала обновлений бота → ERROR.

Защита секретов (инвариант «секреты не в логи»):
- общий logging.SanitizeErr убирает URL из *url.Error;
- закрыты утечки токена бота (getMe на старте, getFile, Send/Request)
  и api_key TMDB (query-параметр, попадавший в *url.Error на ERROR);
- покрыто тестом internal/logging/sanitize_test.go.

Ревью двумя сабагентами (fable): инфраструктурные и доменные ошибки.
Отложено (не в scope этого коммита): обёртка ErrConflict в
Cancel/Defer/Retry и классификация 500→409/400, обновление docs/conventions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 14:29:38 +03:00
co-authored by Claude Opus 4.8
parent 5c3ef79496
commit f8fb4fabb3
11 changed files with 240 additions and 40 deletions
+26 -13
View File
@@ -225,7 +225,8 @@ func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]strin
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
func (w *Worker) Apply(ctx context.Context, id string) error {
func (w *Worker) Apply(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "apply", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -362,7 +363,8 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте и докачан.
func (w *Worker) Relink(ctx context.Context, id string) error {
func (w *Worker) Relink(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "relink", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -399,7 +401,8 @@ func (w *Worker) Relink(ctx context.Context, id string) error {
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
func (w *Worker) Rerecognize(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "rerecognize", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -417,7 +420,8 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) error {
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error) {
defer func() { w.logCmd(ctx, "refine", id, err) }()
hint = strings.TrimSpace(hint)
if hint == "" {
return fmt.Errorf("refine: empty hint")
@@ -443,7 +447,8 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
// — чтобы LLM пересобрал роли файлов под новый тип.
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) (err error) {
defer func() { w.logCmd(ctx, "set_type", id, err) }()
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
return fmt.Errorf("set type: invalid type %q", mediaType)
}
@@ -474,7 +479,8 @@ func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
// превью пересчитается с учётом правки.
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) (err error) {
defer func() { w.logCmd(ctx, "ignore_file", id, err) }()
src = strings.TrimSpace(src)
if src == "" {
return fmt.Errorf("ignore: empty path")
@@ -503,7 +509,8 @@ func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
}
// Defer паркует задачу в deferred (вернётся в ревью по действию).
func (w *Worker) Defer(ctx context.Context, id string) error {
func (w *Worker) Defer(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "defer", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -523,7 +530,8 @@ func (w *Worker) Defer(ctx context.Context, id string) error {
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
func (w *Worker) Undo(ctx context.Context, id string) error {
func (w *Worker) Undo(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "undo", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -588,7 +596,8 @@ func laidOutLinks(rows []store.FileLink) []layout.Link {
// в транспорте. Доступно из done/orphaned/target_missing; идемпотентно к
// отсутствующей стороне. Source-preflight НЕ делает (цель — снять источник,
// его отсутствие трактуем как уже снятую сторону).
func (w *Worker) Delete(ctx context.Context, id string) error {
func (w *Worker) Delete(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "delete", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -666,7 +675,8 @@ func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (err error) {
defer func() { w.logCmd(ctx, "choose_candidate", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -691,7 +701,8 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
// пиннит как выбранный. provider — из набора tmdb/tvdb/imdb.
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error {
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) (err error) {
defer func() { w.logCmd(ctx, "add_manual_source", id, err) }()
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
@@ -786,7 +797,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) (err error) {
defer func() { w.logCmd(ctx, "set_provider_id", id, err) }()
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
@@ -818,7 +830,8 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
// пины названия/года (источник — распознавание нейронкой).
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "clear_provider", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
+27 -2
View File
@@ -775,9 +775,33 @@ func (w *Worker) shouldNotifyFail(id string) bool {
return true
}
// logCmd — единый чокпоинт логирования исхода команды воркера
// (apply/cancel/retry/…), вызываемой транспортами. Конвенция (logging.md,
// раздел «Ошибки»): доменную ошибку логирует граница домена ровно один раз, а
// транспорты (HTTP/web/Telegram) — нет. Команды воркера и есть эта граница.
//
// Уровень — по адресату: штатный отказ по состоянию/наличию
// (ErrConflict/ErrNotReady/ErrNotFound) адресован пользователю, он уже получил
// ответ на поверхности — DEBUG; всё прочее (сбой БД/ФС/зависимости) адресовано
// команде — ERROR. Успех (err == nil) — молча. Ставится в defer при именованном
// возврате, поэтому видит финальную ошибку и scoped-логгер, накопленный в ctx.
func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
if err == nil {
return
}
log := logctx.FromOr(ctx, w.log)
switch {
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, store.ErrNotFound):
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
default:
log.Error("command failed", "command", cmd, "download_id", id, "error", err)
}
}
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
// раздачу (источник неприкосновенен).
func (w *Worker) Cancel(ctx context.Context, id string) error {
func (w *Worker) Cancel(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "cancel", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()
@@ -797,7 +821,8 @@ func (w *Worker) Cancel(ctx context.Context, id string) error {
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
// qBittorrent и возвращает в downloading.
func (w *Worker) Retry(ctx context.Context, id string) error {
func (w *Worker) Retry(ctx context.Context, id string) (err error) {
defer func() { w.logCmd(ctx, "retry", id, err) }()
w.mu.Lock()
defer w.mu.Unlock()