Логирование: доменная граница ошибок + защита секретов в логах
Приём торрента через 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:
+26
-13
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user