From 203a18ae83f6ddb9646eede4f1f557dc932ae0b4 Mon Sep 17 00:00:00 2001 From: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:53:52 +0800 Subject: [PATCH] feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(observability): OpenTelemetry logs+traces with secret redaction Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged. Bootstrap (internal/telemetry): - Setup() builds one shared resource, a TracerProvider (parent-based trace-id ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator from env. It installs NO MeterProvider — metrics stay on Prometheus, and the built-in no-op global MeterProvider keeps the trace instrumentation libs from double-emitting. Shutdown is deferred with a flush timeout. - Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the shared LevelVar and best-effort so a failing collector can't break the console or DB branches. stderr + opslog stay untouched. Secret redaction (internal/logredact): - A slog.Handler masks secret-keyed attributes (password, token, api_key, authorization, cookie, ...) — including .With-bound attrs, nested groups, secret-keyed group subtrees, and values behind a LogValuer — on the console and OTLP sinks, with a no-op fast path when a record has no secret keys. opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one marker list. Rotation is infra-managed (no custom file sink): container runtime for stderr, collector/backend for OTLP, opslog partition-pruning for the DB. Documented in docs/architecture/observability.md. Verification: go build ./..., go vet, gofmt -l — clean; go test ./internal/telemetry/ ./internal/logredact/ -race pass. AI-use disclosure: implemented with AI assistance (Claude Code), including adversarial reviews that hardened the bootstrap and fixed two redaction leak paths; reviewed by the author. * refactor(observability): slog context+component sweep, sloglint gate (phase 3) Part of #265. Builds on the OTel bootstrap + redaction commit. Standardizes every log call site onto the context-carrying slog variants so records correlate with the active OpenTelemetry trace, and locks the standard in with a machine gate so future code (human- or AI-authored) can't drift back. - Call-site sweep: converted the remaining slog.(...) calls to the slog.Context(ctx, ...) form wherever a context.Context is in scope (background/init calls with no ctx are left as-is), across 183 files. Applied via a type-aware AST codemod. Log levels and message strings are preserved verbatim; a component attr (canonical per-package name) is added to direct package-level slog calls. Bound-logger calls keep their existing .With bindings. The main.go and telemetry package conversions rode with their file in the previous commit to keep each file within a single commit. - Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg, key-naming-case=snake, no-mixed-args. After the sweep all four report zero violations repo-wide (tests included), so make lint / CI now blocks any regression to the non-context form. The gate ships with the sweep because it cannot be green until the legacy sites are converted. Metrics remain on Prometheus; no behavior change to /metrics or Grafana. Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4 rules) 0 violations repo-wide; log levels verified unchanged. AI-use disclosure: implemented with AI assistance (Claude Code), including the codemod; reviewed by the author. * fix(observability): honor per-signal OTLP protocol and secret WithGroup names Two Codex review findings on PR #290: - telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector setups (e.g. HTTP logs + gRPC traces) build the right exporter. - logredact: entering a group whose name is secret-bearing (e.g. WithGroup("authorization")) now masks every leaf in that subtree, matching how slog.Group("authorization", ...) is masked as a whole. * fix(observability): address review feedback on telemetry bootstrap - Telemetry setup failure no longer kills boot: Setup returns usable no-op providers alongside the error and main logs and continues with telemetry disabled, honoring the best-effort contract. - Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_* variants); unsupported values fall back to parentbased_traceidratio. - Attach node identity as semconv service.instance.id instead of the non-semconv node.name. - Rename opslog retention-scope log attrs to target_component/target_level so they no longer collide with the canonical component routing key, and tag those lines with component=opslog. - Fix stale levelGated comment casing; use WarnContext in the telemetry shutdown defer; document the LogValuer double-resolve on the redaction slow path. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .golangci.yml | 15 ++ cmd/silo/main.go | 107 +++++--- docs/architecture/observability.md | 205 ++++++++++++++ .../2026-07-02-opentelemetry-observability.md | 252 ++++++++++++++++++ go.mod | 19 ++ go.sum | 27 ++ internal/activitylog/cleanup.go | 16 +- internal/activitylog/consumer.go | 18 +- internal/adminjob/image_cache_cleanup.go | 2 +- internal/adminjob/runner.go | 8 +- internal/ai/llm/client.go | 4 +- internal/api/handlers/admin.go | 22 +- internal/api/handlers/admin_images.go | 14 +- internal/api/handlers/admin_intro.go | 14 +- internal/api/handlers/admin_logs.go | 4 +- .../api/handlers/admin_marker_providers.go | 2 +- internal/api/handlers/admin_match.go | 12 +- internal/api/handlers/admin_split.go | 24 +- internal/api/handlers/admin_stats.go | 2 +- internal/api/handlers/autoscan.go | 4 +- internal/api/handlers/calendar.go | 2 +- internal/api/handlers/catalog.go | 6 +- internal/api/handlers/downloads.go | 10 +- internal/api/handlers/events_publish.go | 4 +- internal/api/handlers/events_ws.go | 8 +- internal/api/handlers/history_import.go | 4 +- internal/api/handlers/history_import_admin.go | 4 +- internal/api/handlers/items.go | 8 +- internal/api/handlers/libraries.go | 144 +++++----- internal/api/handlers/library_collections.go | 38 +-- internal/api/handlers/markers.go | 28 +- internal/api/handlers/metadata_ai.go | 4 +- internal/api/handlers/nodes.go | 14 +- internal/api/handlers/people.go | 6 +- internal/api/handlers/playback.go | 116 ++++---- .../api/handlers/playback_lazy_markers.go | 22 +- internal/api/handlers/playback_missing.go | 2 +- internal/api/handlers/plugins.go | 70 ++--- internal/api/handlers/policy.go | 2 +- internal/api/handlers/policy_test.go | 4 +- internal/api/handlers/profile_refresh.go | 2 +- internal/api/handlers/profiles.go | 8 +- internal/api/handlers/progress.go | 2 +- internal/api/handlers/recommendations.go | 18 +- internal/api/handlers/sections.go | 26 +- internal/api/handlers/server_control.go | 2 +- internal/api/handlers/session_ws.go | 6 +- internal/api/handlers/settings.go | 2 +- internal/api/handlers/stream.go | 14 +- internal/api/handlers/subtitle_ai.go | 4 +- internal/api/handlers/subtitle_search.go | 4 +- internal/api/handlers/system.go | 2 +- internal/api/handlers/taste_seed.go | 8 +- .../api/handlers/user_collection_imports.go | 4 +- internal/api/handlers/watch_tonight.go | 12 +- internal/api/handlers/watch_tonight_cards.go | 12 +- internal/api/handlers/webhook_sync.go | 2 +- internal/api/middleware/request_logger.go | 2 +- internal/audiobooks/abs/access_log.go | 4 +- .../audiobooks/abs/author_series_handler.go | 8 +- internal/audiobooks/abs/bookmarks_handler.go | 8 +- .../audiobooks/abs/collections_handler.go | 30 +-- .../abs/continue_listening_handler.go | 4 +- internal/audiobooks/abs/extras_handlers.go | 2 +- internal/audiobooks/abs/handler.go | 22 +- internal/audiobooks/abs/libraries_handler.go | 4 +- .../audiobooks/abs/listening_stats_handler.go | 12 +- internal/audiobooks/abs/login.go | 30 +-- internal/audiobooks/abs/native_sessions.go | 12 +- internal/audiobooks/abs/play_response.go | 4 +- internal/audiobooks/abs/playlists_handler.go | 48 ++-- internal/audiobooks/abs/progress.go | 2 +- internal/audiobooks/abs/rss_feeds_handler.go | 10 +- internal/audiobooks/abs/session_local.go | 6 +- .../abs/smart_collections_handler.go | 26 +- internal/audiobooks/enrichment.go | 36 +-- internal/audiobooks/podcastfeed/refresher.go | 6 +- internal/auth/oauth_handler.go | 14 +- internal/autoscan/service.go | 28 +- internal/catalog/collection_sync_scheduler.go | 12 +- internal/catalog/detail.go | 14 +- .../catalog/library_collection_service.go | 44 +-- internal/catalog/person_repo.go | 4 +- internal/catalog/search_indexer.go | 2 +- internal/catalog/search_service.go | 2 +- internal/catalog/semantic_coverage.go | 8 +- internal/catalog/smart_count_refresher.go | 8 +- internal/chapterthumbs/service.go | 68 ++--- internal/downloads/artifacts.go | 52 ++-- internal/downloads/offline.go | 2 +- internal/downloads/service.go | 14 +- internal/downloads/subscription_service.go | 8 +- internal/ebooks/enrichment.go | 38 +-- internal/historyimport/plex_admin_provider.go | 2 +- internal/historyimport/plex_provider.go | 2 +- internal/historyimport/service.go | 12 +- internal/historyimport/service_admin.go | 4 +- internal/historyimport/service_maintenance.go | 4 +- internal/intromarkers/analyzer.go | 16 +- internal/jellycompat/access_filter.go | 2 +- internal/jellycompat/auth.go | 8 +- internal/jellycompat/auth_api_key.go | 2 +- internal/jellycompat/cleanup.go | 4 +- internal/jellycompat/content_direct.go | 2 +- internal/jellycompat/handlers_autoscan.go | 10 +- internal/jellycompat/handlers_collections.go | 6 +- internal/jellycompat/handlers_items.go | 12 +- internal/jellycompat/handlers_playback.go | 6 +- internal/jellycompat/handlers_websocket.go | 8 +- internal/jellycompat/logging.go | 2 +- .../jellycompat/playback_sessions_postgres.go | 4 +- internal/jellycompat/profile_refresh.go | 2 +- internal/jellycompat/query.go | 2 +- internal/jellycompat/streams.go | 26 +- internal/jellycompat/userdata_direct.go | 2 +- internal/libraryingest/executor.go | 10 +- internal/logredact/redact.go | 160 +++++++++++ internal/logredact/redact_test.go | 166 ++++++++++++ internal/manga/enrichment.go | 36 +-- internal/markers/contribute.go | 4 +- internal/metadata/chain.go | 6 +- internal/metadata/image_cache_processor.go | 8 +- internal/metadata/image_resolver.go | 10 +- internal/metadata/person_refresh.go | 4 +- internal/metadata/provider_id_integrity.go | 2 +- internal/metadata/service.go | 132 ++++----- internal/metadata/translation/service.go | 16 +- internal/metadata/worker.go | 90 +++---- internal/nodeconfig/watcher.go | 6 +- internal/nodepool/health.go | 6 +- internal/noderecipe/store.go | 4 +- internal/nodesessions/tracker.go | 14 +- .../notifications/account_channel_engine.go | 10 +- .../notifications/availability_detector.go | 10 +- internal/notifications/channel_dispatcher.go | 8 +- internal/notifications/discord_dm.go | 2 +- internal/notifications/fanout_worker.go | 8 +- internal/notifications/interest_hooks.go | 2 +- internal/notifications/interest_updater.go | 6 +- .../notifications/operational_dispatch.go | 4 +- .../operational_dispatch_test.go | 3 +- internal/notifications/push_sender.go | 6 +- internal/notifications/request_notifier.go | 2 +- .../server_channel_request_notifier.go | 2 +- .../notifications/server_channel_worker.go | 16 +- internal/notifications/system.go | 14 +- internal/notifications/webhook_sender.go | 14 +- internal/notifications/webpush_sender.go | 6 +- internal/opslog/cleanup.go | 27 +- internal/opslog/consumer.go | 18 +- internal/opslog/handler.go | 12 +- .../playback/chapter_thumbnail_notifier.go | 12 +- internal/playback/ffmpeg_log_sink.go | 2 +- internal/playback/marker_update_notifier.go | 10 +- internal/playback/session.go | 2 +- internal/playback/subtitle_ready_notifier.go | 14 +- internal/playback/subtitle_stream.go | 4 +- internal/playback/transcode_manager.go | 12 +- internal/plugins/archive_cache.go | 4 +- internal/plugins/auto_update.go | 12 +- internal/plugins/catalog_service.go | 4 +- internal/plugins/event_dispatcher.go | 8 +- internal/plugins/http_proxy.go | 2 +- internal/plugins/service.go | 14 +- internal/plugins/service_connection.go | 8 +- internal/policy/decisionlog_cleanup.go | 16 +- internal/policy/system_test.go | 3 +- internal/proxy/server.go | 16 +- internal/ratelimit/middleware.go | 2 +- internal/ratelimit/redis.go | 2 +- internal/recommendations/embeddings/client.go | 4 +- internal/recommendations/reader.go | 2 +- internal/recommendations/similar.go | 12 +- internal/recommendations/taste.go | 2 +- internal/recommendations/worker.go | 32 +-- internal/requests/notify.go | 8 +- internal/requests/presence.go | 2 +- internal/requests/service.go | 10 +- internal/s3client/client.go | 4 +- internal/scanner/audiobook_cover.go | 4 +- internal/scanner/audiobook_scan.go | 36 +-- internal/scanner/ebook_scan.go | 30 +-- internal/scanner/extras.go | 12 +- internal/scanner/manga_scan.go | 18 +- internal/scanner/podcast_scan.go | 16 +- internal/scanner/scanner.go | 76 +++--- internal/scanqueue/service.go | 2 +- internal/sections/fetcher.go | 14 +- internal/sections/trending_refresher.go | 6 +- internal/subtitles/ai/service.go | 12 +- internal/taskmanager/manager.go | 12 +- internal/taskmanager/manager_test.go | 9 +- .../taskmanager/tasks/refresh_metadata.go | 2 +- internal/taskmanager/tasks/scan_libraries.go | 2 +- internal/telemetry/config.go | 168 ++++++++++++ internal/telemetry/config_test.go | 155 +++++++++++ internal/telemetry/loghandler.go | 87 ++++++ internal/telemetry/loghandler_test.go | 95 +++++++ internal/telemetry/telemetry.go | 195 ++++++++++++++ internal/telemetry/telemetry_test.go | 71 +++++ internal/transcodenode/server.go | 28 +- internal/usercollections/scheduler.go | 10 +- internal/usercollections/sync.go | 2 +- internal/watchlist/maintainer.go | 4 +- internal/watchsync/lists.go | 2 +- .../watchsync/providers/simkl/provider.go | 2 +- internal/watchsync/service.go | 10 +- internal/webhooksync/service.go | 6 +- internal/worker/cleanup.go | 2 +- scripts/backfill-library-matching/main.go | 3 +- 210 files changed, 2972 insertions(+), 1313 deletions(-) create mode 100644 docs/architecture/observability.md create mode 100644 docs/superpowers/plans/2026-07-02-opentelemetry-observability.md create mode 100644 internal/logredact/redact.go create mode 100644 internal/logredact/redact_test.go create mode 100644 internal/telemetry/config.go create mode 100644 internal/telemetry/config_test.go create mode 100644 internal/telemetry/loghandler.go create mode 100644 internal/telemetry/loghandler_test.go create mode 100644 internal/telemetry/telemetry.go create mode 100644 internal/telemetry/telemetry_test.go diff --git a/.golangci.yml b/.golangci.yml index 9bc83c86..c1f1c918 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,12 +23,27 @@ linters: - ineffassign - misspell - nilerr + - sloglint - staticcheck - unconvert - unused - wastedassign settings: + sloglint: + # Require the context-carrying slog variants (InfoContext, …) wherever a + # context.Context is in scope, so log records correlate with the active + # OpenTelemetry trace. "scope" (not "all") allows the plain Info/… form in + # background/init code where no ctx exists. Enforced repo-wide after the + # phase-3 sweep converted the ~1,159 legacy call sites. + context: "scope" + # Message must be a constant, not a built string; move dynamic parts to attrs. + static-msg: true + # Attribute keys are snake_case (component, request_id, trace_id, …). + key-naming-case: snake + # Don't mix key-value pairs and slog.Attr in one call. + no-mixed-args: true + errcheck: check-type-assertions: true diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 501982a6..c6aada80 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -60,6 +60,7 @@ import ( "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/literaryworks" "github.com/Silo-Server/silo-server/internal/logfilter" + "github.com/Silo-Server/silo-server/internal/logredact" "github.com/Silo-Server/silo-server/internal/logstream" "github.com/Silo-Server/silo-server/internal/mail" "github.com/Silo-Server/silo-server/internal/manga" @@ -93,6 +94,7 @@ import ( taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository" "github.com/Silo-Server/silo-server/internal/taskmanager/tasks" "github.com/Silo-Server/silo-server/internal/taskmanager/triggers" + "github.com/Silo-Server/silo-server/internal/telemetry" "github.com/Silo-Server/silo-server/internal/transcodenode" "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userdb" @@ -130,12 +132,26 @@ func resolvePluginCacheDir() string { return filepath.Join(os.TempDir(), "silo-plugins") } -func buildBaseHandler(format string, level slog.Leveler) slog.Handler { +func buildBaseHandler(format string, level slog.Leveler, otelHandler slog.Handler) slog.Handler { opts := &slog.HandlerOptions{Level: level} + var console slog.Handler if strings.EqualFold(format, "json") { - return slog.NewJSONHandler(os.Stderr, opts) + console = slog.NewJSONHandler(os.Stderr, opts) + } else { + console = slog.NewTextHandler(os.Stderr, opts) } - return slog.NewTextHandler(os.Stderr, opts) + if otelHandler == nil { + // Redact secrets before they reach stderr (the opslog DB path redacts + // separately when flattening rows). + return logredact.New(console) + } + // Fan out to the console and the OTel bridge. The OTel branch is level-gated + // by the shared level var so console and OTLP share one verbosity knob (see + // telemetry.LevelGated) — otherwise slog.MultiHandler.Enabled would OR the + // branches and export Debug records while stderr stays silent. The whole + // fan-out is wrapped in secret redaction so console and OTLP both emit + // masked output (the opslog DB path redacts separately). + return logredact.New(telemetry.FanOut(console, telemetry.LevelGated(otelHandler, level))) } func parseLogLevel(level string) slog.Level { @@ -178,7 +194,7 @@ func configureOperationalLogging( // Non-fatal: a partition hiccup must not crash-loop the server (see the // operational_logs partition incident). Writes fall back to the default // partition and the periodic cleanup retries EnsureFuturePartitions. - slog.Warn("ensure operational log partitions; continuing in degraded mode", "error", err) + slog.WarnContext(ctx, "ensure operational log partitions; continuing in degraded mode", "component", "app", "error", err) } var operationalWriter opslog.Writer @@ -221,7 +237,7 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon opts, err := database.LoadPostgresTuneOptionsFromEnv(appMaxConnections) if err != nil { - slog.Warn("postgres auto-tuning disabled", "error", err) + slog.WarnContext(ctx, "postgres auto-tuning disabled", "component", "app", "error", err) return } if !opts.Enabled { @@ -233,14 +249,14 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon result, err := database.ApplyPostgresTuning(tuneCtx, pool, opts) for _, failure := range result.Failures { - slog.Warn("postgres auto-tuning setting failed", + slog.WarnContext(ctx, "postgres auto-tuning setting failed", "component", "app", "name", failure.Name, "value", failure.Value, "error", failure.Err, ) } if err != nil { - slog.Warn("postgres auto-tuning failed", + slog.WarnContext(ctx, "postgres auto-tuning failed", "component", "app", "error", err, "applied", result.Applied, "failures", len(result.Failures), @@ -248,7 +264,7 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon return } - slog.Info("postgres auto-tuning applied", + slog.InfoContext(ctx, "postgres auto-tuning applied", "component", "app", "profile", opts.Profile, "postgres_major", result.PostgresMajorVersion, "settings", result.Applied, @@ -265,12 +281,12 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon "database_size_bytes", result.DatabaseSizeBytes, ) if len(result.RestartRequired) > 0 { - slog.Warn("postgres restart required to finish applying auto-tuned settings", + slog.WarnContext(ctx, "postgres restart required to finish applying auto-tuned settings", "component", "app", "settings", strings.Join(result.RestartRequired, ","), ) } if len(result.Reset) > 0 { - slog.Info("postgres auto-tuning reset stale settings", + slog.InfoContext(ctx, "postgres auto-tuning reset stale settings", "component", "app", "settings", strings.Join(result.Reset, ","), ) } @@ -285,25 +301,25 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) { settingsN, err := settings.BackfillSensitiveSettings(ctx) if err != nil { - slog.Error("secret backfill: sensitive settings", "error", err) + slog.ErrorContext(ctx, "secret backfill: sensitive settings", "component", "app", "error", err) } columnsN, err := secret.BackfillColumns(ctx, pool, cipher, secret.ColumnBackfillTargets()) if err != nil { - slog.Error("secret backfill: credential columns", "error", err) + slog.ErrorContext(ctx, "secret backfill: credential columns", "component", "app", "error", err) } historyServersN, err := historyimport.NewRepository(pool, cipher).BackfillSessionServerSecrets(ctx) if err != nil { - slog.Error("secret backfill: history import session server credentials", "error", err) + slog.ErrorContext(ctx, "secret backfill: history import session server credentials", "component", "app", "error", err) } // The arr resolver is the encrypting settings decorator: it decrypts a // sensitive target (e.g. requests.radarr.api_key) or passes through a // plaintext custom key, exactly replicating the deleted resolveAPIKey. arrN, err := secret.BackfillReferencedColumns(ctx, pool, cipher, settings.Get, secret.ArrKeyBackfillTargets()) if err != nil { - slog.Error("secret backfill: arr api keys", "error", err) + slog.ErrorContext(ctx, "secret backfill: arr api keys", "component", "app", "error", err) } if total := settingsN + columnsN + historyServersN + arrN; total > 0 { - slog.Info("secret backfill: encrypted plaintext credentials at rest", + slog.InfoContext(ctx, "secret backfill: encrypted plaintext credentials at rest", "component", "app", "settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total) } } @@ -541,7 +557,34 @@ func main() { // the config watcher in integrated mode. logLevelVar := new(slog.LevelVar) logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel)) - baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar) + + // Bootstrap OpenTelemetry (logs + traces) before installing the log handler + // chain. Setup depends only on OTEL_* / SILO_OTEL_ENABLED env (not the DB), + // so it is safe to call here. When disabled, this is fully dormant: no + // providers are installed and telemetryShutdown is a no-op. + telemetryCfg := telemetry.LoadConfig(nodeID) + telemetryProviders, telemetryShutdown, err := telemetry.Setup(ctx, telemetryCfg) + if err != nil { + // Telemetry is best-effort: a malformed OTEL_* environment must not + // crash-loop the server. Setup installed no globals and returned no-op + // providers, so continue with telemetry disabled. + slog.ErrorContext(ctx, "telemetry setup failed; continuing with telemetry disabled", "component", "app", "error", err) + telemetryCfg.Enabled = false + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := telemetryShutdown(shutdownCtx); err != nil { + slog.WarnContext(shutdownCtx, "telemetry shutdown error", "component", "app", "error", err) + } + }() + + var otelLogHandler slog.Handler + if telemetryCfg.Enabled { + otelLogHandler = telemetry.NewOTelHandler(telemetryProviders.LoggerProvider) + } + + baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar, otelLogHandler) quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet) slog.SetDefault(slog.New(quietFilter)) @@ -576,7 +619,7 @@ func main() { if mode == "proxy" || mode == "transcode" { redisClient, err := cache.NewRedisClient(cfg.Redis) if err != nil || redisClient == nil { - slog.Error("redis is required for "+mode+" mode", "error", err) + slog.Error("redis is required for this mode", "mode", mode, "error", err) os.Exit(1) } @@ -715,7 +758,7 @@ func main() { // must not be skipped because the admin request was canceled. if key == clientip.SettingTrustedProxies && ipResolver != nil { if cidrs, loadErr := clientip.LoadTrustedCIDRs(context.Background(), settingsRepo); loadErr != nil { - slog.Warn("clientip config reload failed", "error", loadErr) + slog.WarnContext(context.Background(), "clientip config reload failed", "component", "app", "error", loadErr) } else { ipResolver.UpdateTrustedCIDRs(cidrs) } @@ -1007,7 +1050,7 @@ func main() { settingsRepo, markerPluginResolver, ); err != nil { - slog.Warn("reload marker plugin providers failed", "error", err) + slog.WarnContext(ctx, "reload marker plugin providers failed", "component", "app", "error", err) } }) } @@ -1116,7 +1159,7 @@ func main() { if pluginService != nil && pluginInstallationStore != nil { reloadImageResolvers := func(ctx context.Context) { if err := reloadPluginImageResolvers(ctx, pluginInstallationStore, imageResolver, pluginService); err != nil { - slog.Warn("failed to reload plugin image resolvers", "error", err) + slog.WarnContext(ctx, "failed to reload plugin image resolvers", "component", "app", "error", err) } } pluginService.AddLifecycleHook(reloadImageResolvers) @@ -1267,9 +1310,9 @@ func main() { matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled()) backgroundInit = append(backgroundInit, func(ctx context.Context) { if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(ctx); err != nil { - slog.Warn("failed to clean legacy series group queue rows", "error", err) + slog.WarnContext(ctx, "failed to clean legacy series group queue rows", "component", "app", "error", err) } else if cleaned > 0 { - slog.Info("cleaned legacy series group queue rows", "count", cleaned) + slog.InfoContext(ctx, "cleaned legacy series group queue rows", "component", "app", "count", cleaned) } }) } @@ -1278,7 +1321,7 @@ func main() { start := time.Now() enabledFolders, err := deps.FolderRepo.GetEnabled(ctx) if err != nil { - slog.Warn("failed to seed metadata queues", "error", err) + slog.WarnContext(ctx, "failed to seed metadata queues", "component", "app", "error", err) return } seedMovieQueue := func(folderID int) { @@ -1286,7 +1329,7 @@ func main() { return } if err := movieQueueRepo.SyncForFolder(ctx, folderID); err != nil { - slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err) + slog.WarnContext(ctx, "failed to seed movie match queue", "component", "app", "folder_id", folderID, "error", err) } } seedSeriesQueue := func(folderID int) { @@ -1294,7 +1337,7 @@ func main() { return } if err := seriesQueueRepo.SyncForFolder(ctx, folderID); err != nil { - slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err) + slog.WarnContext(ctx, "failed to seed series root queue", "component", "app", "folder_id", folderID, "error", err) } } for _, folder := range enabledFolders { @@ -1311,7 +1354,7 @@ func main() { seedMovieQueue(folder.ID) } } - slog.Info("deferred init: metadata match queues seeded", "folders", len(enabledFolders), "duration", time.Since(start)) + slog.InfoContext(ctx, "deferred init: metadata match queues seeded", "component", "app", "folders", len(enabledFolders), "duration", time.Since(start)) }) } @@ -1539,7 +1582,7 @@ func main() { WithUserStoreProvider(userStoreProvider) backgroundInit = append(backgroundInit, func(ctx context.Context) { if err := watchProviderService.SweepOpenScrobbles(ctx); err != nil { - slog.Warn("failed to sweep open watch provider scrobbles", "error", err) + slog.WarnContext(ctx, "failed to sweep open watch provider scrobbles", "component", "app", "error", err) } }) } @@ -1655,7 +1698,7 @@ func main() { } cidrs, loadErr := clientip.LoadTrustedCIDRs(context.Background(), settingsRepo) if loadErr != nil { - slog.Warn("clientip config reload failed", "error", loadErr) + slog.WarnContext(context.Background(), "clientip config reload failed", "component", "app", "error", loadErr) return } ipResolver.UpdateTrustedCIDRs(cidrs) @@ -1672,7 +1715,7 @@ func main() { } cidrs, parseErr := clientip.ParseCIDRs(raw) if parseErr != nil { - slog.Warn("clientip config reload failed", "error", parseErr) + slog.WarnContext(context.Background(), "clientip config reload failed", "component", "app", "error", parseErr) return } ipResolver.UpdateTrustedCIDRs(cidrs) @@ -2763,7 +2806,7 @@ func reloadPluginImageResolvers( case sdkcapability.ImageResolver: schemes, priority := imageResolverCapabilityConfig(capability) if len(schemes) == 0 { - slog.Warn("plugin image resolver capability has no valid schemes", + slog.WarnContext(ctx, "plugin image resolver capability has no valid schemes", "component", "app", "installation_id", installation.ID, "capability_id", capability.ID) continue @@ -2786,7 +2829,7 @@ func reloadPluginImageResolvers( case sdkcapability.MetadataProvider: scheme := strings.TrimSpace(capability.ID) if !metadata.ValidImageResolverScheme(scheme) { - slog.Warn("skipping legacy metadata image resolver with invalid scheme", + slog.WarnContext(ctx, "skipping legacy metadata image resolver with invalid scheme", "component", "app", "installation_id", installation.ID, "capability_id", capability.ID) continue @@ -2808,7 +2851,7 @@ func reloadPluginImageResolvers( } resolver.ReplaceSources(registrations) - slog.Info("reloaded plugin image resolvers", "sources", len(registrations)) + slog.InfoContext(ctx, "reloaded plugin image resolvers", "component", "app", "sources", len(registrations)) return nil } diff --git a/docs/architecture/observability.md b/docs/architecture/observability.md new file mode 100644 index 00000000..8e6e919c --- /dev/null +++ b/docs/architecture/observability.md @@ -0,0 +1,205 @@ +# Observability (OpenTelemetry logs + traces) + +Silo emits structured **logs** and distributed **traces** via OpenTelemetry (OTLP), +in addition to the existing stderr and `opslog` database pipeline. The feature is +**opt-in and default-off**: with no `OTEL_*` / `SILO_OTEL_ENABLED` configuration the +server behaves exactly as before (stderr + `opslog` only). + +**Metrics are not part of OpenTelemetry here.** They remain on Prometheus +(`client_golang`, the `/metrics` endpoint, and the domain metrics in +`internal/api/middleware`). See [Metrics](#metrics-stay-on-prometheus) below. + +## Enabling it + +Telemetry turns on when **either** `SILO_OTEL_ENABLED` is truthy **or** +`OTEL_EXPORTER_OTLP_ENDPOINT` is set. + +| Variable | Purpose | Default | +| --- | --- | --- | +| `SILO_OTEL_ENABLED` | Master gate (`1`/`true`/`yes`/`on`). | off | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector endpoint; also implicitly enables telemetry. | — | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` (default) or `http/protobuf`. | `grpc` | +| `OTEL_SERVICE_NAME` | `service.name` resource attribute. | `silo-server` | +| `OTEL_SERVICE_VERSION` | `service.version` resource attribute. | unset | +| `OTEL_TRACES_SAMPLER` | `always_on`, `always_off`, `traceidratio`, `parentbased_always_on`, `parentbased_always_off`, or `parentbased_traceidratio`. Unsupported values (e.g. `jaeger_remote`) fall back to the default. | `parentbased_traceidratio` | +| `OTEL_TRACES_SAMPLER_ARG` | Trace-id ratio for the ratio-based samplers (0–1; clamps >1 to 1). | `1.0` | + +The node identity is attached as the `service.instance.id` resource attribute, so +multiple Silo nodes sharing one `service.name` stay distinguishable in the backend. + +All other `OTEL_EXPORTER_OTLP_*` knobs (headers, TLS, per-signal endpoints) are read +directly from the environment by the OTLP exporters — the environment is the single +source of truth for exporter wiring. + +The endpoint/exporter connection is lazy and non-blocking: an unreachable collector +does **not** delay or crash startup. Setup failure is also non-fatal: if the `OTEL_*` +environment is malformed (e.g. an unparseable endpoint URL), the server logs an error +and keeps running with telemetry disabled rather than crash-looping. + +## Architecture + +The bootstrap lives in `internal/telemetry` (`Setup` in `telemetry.go`). When enabled +it builds one shared `resource.Resource`, a `TracerProvider` (sampler per +`OTEL_TRACES_SAMPLER`, batched OTLP exporter), a `LoggerProvider` (batched OTLP exporter), and the W3C +`TraceContext + Baggage` propagator. Shutdown is deferred in `cmd/silo/main.go` with a +flush timeout so buffered spans/logs drain on exit. + +### Log handler chain + +Logs are bridged, not rerouted. The OTel `otelslog` handler is added to the existing +`slog` handler chain via **fan-out**, so every record still reaches stderr and the +`opslog` DB/admin-stream pipeline unchanged: + +``` +slog.Context(ctx, …) + → opslog.Handler (DB capture + admin stream, when level ≥ capture) + → logfilter.Handler (drops quieted subsystem prefixes) + → slog.MultiHandler + ├── stderr (json/text) + └── otelslog → OTLP (level-gated + best-effort) +``` + +Three properties matter: + +- **Level-gated.** `slog.MultiHandler.Enabled` ORs its children, so the OTel branch is + wrapped in a level gate bound to the shared `LevelVar`; console and OTLP share one + verbosity knob. Without this, Debug records would be built and exported even at + `log_level=info`. +- **Best-effort.** The OTel branch never propagates an export error, so a failing + collector cannot break the console or DB branches. +- **Redacted.** The whole fan-out is wrapped in `internal/logredact`, so console and OTLP + emit secret-masked output. Redaction is key-based (`password`, `secret`, `token`, + `api_key`, `authorization`, `cookie`, …); a matching attribute's value becomes + `[REDACTED]`, including attrs bound via `.With(...)` and nested groups. The marker list + is shared with the opslog DB path (`opslog.shouldRedact` → `logredact.SecretKey`) so all + sinks agree. Limitation: values are not scanned, so a secret embedded in a free-text + message or under a non-secret key is not caught. + +## Logging conventions (enforced) + +Use the **context-carrying** slog variants and tag the subsystem: + +```go +slog.InfoContext(ctx, "scanner: starting", "component", "scanner", "folder_id", id) +``` + +Rules, enforced by `sloglint` in `make lint` (`.golangci.yml`): + +- **`slog.Context(ctx, …)`** wherever a `context.Context` is in scope (so records + carry the active `trace_id`/`span_id`). The plain `slog.Info(…)` form is only allowed + where no `ctx` exists (early boot, top-level goroutines). +- **Static message** — the message is a constant; move dynamic parts to attributes. +- **snake_case attribute keys** — `component`, `request_id`, `trace_id`, `user_id`, … +- **No mixed args** — don't combine key-value pairs and `slog.Attr` in one call. + +### Component classification + +Every direct `slog.*Context` call carries a `component` attribute. `opslog` classifies +records by that attribute first, falling back to `opslog.InferComponent` (the `subsystem:` +prefix in the message) when no attribute is present — bound loggers that already set +`component` via `.With(...)` are left as-is. + +**Limitation:** `sloglint` enforces the *shape* (context variant, snake keys, static +message) but **cannot** enforce that a `component` attribute is present. That convention +rests on this doc, the canonical list below, and review. + +Canonical component values (first path segment under `internal/`; `app` for `cmd/silo`): + +`activitylog`, `adminjob`, `ai`, `api`, `app`, `audiobooks`, `auth`, `autoscan`, +`catalog`, `chapterthumbs`, `downloads`, `ebooks`, `historyimport`, `jellycompat`, +`libraryingest`, `manga`, `metadata`, `nodeconfig`, `nodepool`, `noderecipe`, +`nodesessions`, `notifications`, `opslog`, `playback`, `plugins`, `policy`, `proxy`, `ratelimit`, +`recommendations`, `requests`, `scanner`, `scanqueue`, `sections`, `taskmanager`, +`telemetry`, `transcodenode`, `watchlist`, `watchsync`, `webhooksync`, `worker`. + +## Metrics stay on Prometheus + +OpenTelemetry here installs **no MeterProvider**. Metrics continue to flow through the +existing Prometheus `client_golang` instrumentation to `/metrics`; Grafana scraping is +unaffected. + +This is deliberate and guarded: the trace-instrumentation libraries (`otelhttp`, etc., +added in later phases) also emit metrics through the *global* MeterProvider. Because none +is set, that global stays the built-in **no-op**, so those metric calls are silently +discarded — no double-counting into Prometheus, no second `/metrics` source. A guard test +asserts `otel.GetMeterProvider()` remains the no-op after `Setup`. Migrating metrics to +the OTel metrics SDK is out of scope. + +## Local collector (example) + +```yaml +# docker-compose.yml (dev) +otel-collector: + image: otel/opentelemetry-collector:latest + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./otelcol.yaml:/etc/otelcol/config.yaml + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP +``` + +```yaml +# otelcol.yaml — minimal debug pipeline +receivers: + otlp: + protocols: + grpc: {} + http: {} +exporters: + debug: + verbosity: detailed +service: + pipelines: + traces: { receivers: [otlp], exporters: [debug] } + logs: { receivers: [otlp], exporters: [debug] } +``` + +Run Silo with `SILO_OTEL_ENABLED=1 OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317` and watch +logs + traces arrive at the collector. Swap `debug` for Loki/Tempo (or a vendor OTLP +endpoint) for real storage; Grafana can then show traces alongside the unchanged +Prometheus metrics. + +## Log retention & rotation + +Silo does **not** write or rotate its own log files. Rotation/retention is handled by the +layer that owns each sink, which is the intended cloud-native split: + +- **Console (stderr)** is owned by the container runtime. Under Docker, configure the + `json-file` driver to cap and rotate on-disk logs, and mount the log directory on a + volume so history survives container recreation (the problem that motivated this work): + + ```yaml + # docker-compose.yml + services: + silo: + logging: + driver: json-file + options: + max-size: "50m" # rotate at 50 MB + max-file: "5" # keep 5 rotated files + ``` + + For a non-Docker deployment, run under a supervisor/journald or pipe to `logrotate`. + +- **OTLP** is the durable, queryable path: the collector/backend (Loki, a vendor, …) owns + retention and is the recommended place to keep searchable history. Enable it (see above) + and set retention on the backend. + +- **opslog (Postgres)** self-prunes: daily partitions with per-component retention and + size caps (`internal/opslog/cleanup.go`). No operator action needed. + +An in-process rotating file sink (e.g. lumberjack) was deliberately **not** added — it +would re-introduce a custom sink the OTLP + runtime split already covers. + +## Known limitations + +- **`log_quiet` also suppresses OTel logs.** The fan-out sits below the quiet filter, so + quieting a subsystem empties its OTLP stream too (OTLP mirrors the console stream). +- **Redaction is key-based, not value-based.** Secrets under a recognized key are masked + on every sink, but a secret embedded in a message string or under an unrecognized key is + not caught (see [Redacted](#log-handler-chain)). +- **Early-boot logs are not exported.** Records emitted before the handler is installed + (DB connect, migrations, tuning) reach stderr only, matching existing `opslog` behavior. +- **Per-subsystem trace propagation into plugins** is a follow-up owned by + `silo-plugin-sdk`; this repo instruments only the host side. diff --git a/docs/superpowers/plans/2026-07-02-opentelemetry-observability.md b/docs/superpowers/plans/2026-07-02-opentelemetry-observability.md new file mode 100644 index 00000000..641eaa65 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-opentelemetry-observability.md @@ -0,0 +1,252 @@ +# OpenTelemetry Observability: Logs + Traces Adoption Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd unless a task explicitly says otherwise. + +**Goal:** Adopt OpenTelemetry for **logs and traces** (metrics explicitly deferred), so Silo emits OTLP-exportable structured logs correlated with distributed traces, without discarding the existing stderr + `opslog` DB/admin-stream pipeline. Standardize all `slog` call sites so every log record can carry `trace_id`/`span_id` when a span is active. + +**Architecture:** Add a single OTel SDK bootstrap module (`internal/telemetry`) that builds a shared `resource.Resource`, a `TracerProvider`, and a `LoggerProvider` from `OTEL_*` env config, with graceful shutdown wired into the existing signal path. Bridge logs by inserting an `otelslog` handler into the existing `slog.Handler` chain via **fan-out** (`slog.MultiHandler`, stdlib in Go 1.26) — the current stderr + `opslog` sinks are untouched; OTel receives the same stream. Standardize the ~1,200 `slog.*` call sites to `slog.*Context(ctx, …)` and to explicit `component` attrs (retiring the `subsystem:` message-prefix convention as the classification signal, while keeping `opslog.InferComponent` as a backward-compatible fallback). Add tracing at the natural seams: chi HTTP middleware, `pgx` pool tracer, `go-redis` hook, outbound `http.Client` transports, host-side plugin gRPC stats handlers, and domain spans for scanner/playback/taskmanager. W3C `traceparent` propagation ties main-server → transcode-node and → plugin-subprocess traces together. + +**Tech Stack:** Go 1.26, `log/slog`, chi/v5, pgx/v5 + pgxpool, go-redis/v9, gRPC, PostgreSQL. New deps: `go.opentelemetry.io/otel` (+ `sdk`, `sdk/trace`, `sdk/log`), OTLP exporters (`otlptracegrpc`/`otlptracehttp`, `otlploggrpc`/`otlploghttp`), `go.opentelemetry.io/contrib/bridges/otelslog`, `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`, `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`, `github.com/redis/go-redis/extra/redisotel/v9`, and a pgx tracer (`github.com/exaring/otelpgx`). + +**Non-goals (explicit):** +- **Metrics stay on Prometheus — and must not break.** The existing `client_golang` instrumentation (`/metrics` on the dedicated `metricsMux` at `cmd/silo/main.go:2078`, `internal/api/middleware/metrics.go`, `internal/api/middleware/domain_metrics.go`) is **not** migrated to the OTel metrics SDK in this plan. Prometheus and OTel run on **separate, parallel rails**: Prometheus → `/metrics` → Grafana scrape (unchanged), OTel → OTLP → traces/logs backend. See the "Prometheus / Grafana coexistence" section below for the one footgun that must be guarded. +- **Plugin-SDK-side instrumentation is out of repo.** Only host-side gRPC handler options live here; the plugin subprocess side is owned by `silo-plugin-sdk` and is a coordination follow-up. +- **Log redaction now covers all sinks.** A shared `internal/logredact` handler masks secret-keyed attrs on console + OTLP (opslog DB path already redacted); `opslog.shouldRedact` delegates to `logredact.SecretKey` so the marker list is single-sourced. (Superseded the earlier "OTel output is raw" risk.) + +--- + +## Validated Findings + +Established by codebase research on the `feat/opentelemetry-plan` branch (based off `origin/main` @ `5172155a`). + +### Logging +- The `slog.Handler` chain is assembled in two stages. Base handler is `slog.NewJSONHandler`/`NewTextHandler(os.Stderr, …)` in `buildBaseHandler` (`cmd/silo/main.go:132-138`), gated by a shared `*slog.LevelVar` (`cmd/silo/main.go:540-544`). +- `internal/logfilter/handler.go` wraps the base handler to drop records whose message has a configured `prefix:` (hot-reloadable `SetQuiet`). Installed as the first default logger at `cmd/silo/main.go:544-545`. +- Once Postgres/settings exist, `configureOperationalLogging` (`cmd/silo/main.go:169-211`) re-wraps: `slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID))` (`cmd/silo/main.go:209`). +- `opslog.Handler.Handle` (`internal/opslog/handler.go:33-93`) forwards to `inner` first, then (if level ≥ capture) flattens attrs, redacts secret-ish keys (`internal/opslog/handler.go:188-196`), and writes an `opslog.Entry` to a `Writer` (Redis list or in-memory chan → `Consumer` → partitioned `operational_logs` table → `logstream.Hub` admin stream). +- **Component classification**: explicit `component` attr, else `inferComponent(message)` splitting on first `:` (`internal/opslog/handler.go:51-54,166-174`), default `"app"`. Correlation attrs already extracted if present: `request_id`, `session_id`, `playback_session_id`, `client_ip`/`remote_addr`, `node_id`, `user_id`. +- **~1,211 `slog.(Info|Warn|Error|Debug)(` call sites** across `internal/`. Heaviest: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35). Dominant idiom is the bare package-level `slog.Info("subsystem: msg", "k", v)` (no context). A few subsystems bind `slog.Default().With("component", …)` on a struct field (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `internal/api/middleware/request_logger.go`). +- **No `trace_id`/`span_id` concept exists** anywhere yet. No automatic context→attr bridging; each call passes correlation attrs manually. + +### Instrumentation seams +- Router is chi/v5. Main middleware stack (`internal/api/router.go:209-231`): `RequestID` (`router.go:213`) → `clientip.Middleware` → `apimw.RequestLogger` (`internal/api/middleware/request_logger.go:19`) → `Recoverer` → `apimw.Metrics` (`internal/api/middleware/metrics.go:35`) → `Compress`. `request_logger.go:53-70` already logs `request_id`/`user_id`/`session_id`/`playback_session_id`. `metrics.go:79-89` has `sanitizePath` (solves `http.route` cardinality). +- jellycompat has its own chi router mounting `middleware.RequestID` (`internal/jellycompat/router.go:30`). `internal/transcodenode/server.go` is a standalone chi server in a separate process. +- Postgres pool built at `internal/database/postgres.go:15` via `pgxpool.ParseConfig`; no `Tracer` set (pgx v5 `QueryTracer` seam). +- Redis clients built at `internal/cache/redis.go:145-254` (two `redis.NewClient` sites); go-redis v9 `AddHook`/`redisotel` seam. +- Outbound `http.Client` literals (not a shared factory): `internal/nodepool/health.go:22-26`, metadata providers (`internal/metadata/tmdb`, `.../trakt`, `.../translation`, `internal/mdblist`), subtitle providers (`internal/subtitles/opensubtitles`, `subdl`, `subsource`), plugin HTTP proxy (`internal/plugins.HTTPProxy`, wired `router.go:149`). +- Host-side plugin gRPC server: `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` — but note this is inside `broker.AcceptAndServe(...)`, a hashicorp/go-plugin **brokered sub-stream** (reverse channel), not the primary host→plugin invocation channel. The load-bearing seam is the client dial (`grpc.NewClient`, pattern in `internal/pluginhost/client_test.go:18`). See Task 7. +- Background entrypoints: scanner `NewScanner` (`internal/scanner/scanner.go:192`), scoped scan `applyScopedScan` (`internal/scanner/scanner.go:1287`); playback `NewSessionManager` (`internal/playback/session.go:152`); taskmanager `RunTask` (`internal/taskmanager/manager.go:231`). +- `context.Context` is threaded pervasively (`Dependencies.AppContext` at `internal/api/router.go:88`; every repo/service method takes `ctx` first). Span context propagates cleanly for the **request/service call chains** — the work there is adding spans + switching call sites to the `…Context` variants. **Exception:** struct-bound loggers whose methods don't take a `ctx` (`taskmanager`, `notifications/*`, `ffmpeg_log_sink.go`) need signature plumbing or stay uncorrelated; see Task 3 Step 3. +- Existing endpoints: `/metrics` on a dedicated `metricsMux` (`cmd/silo/main.go:2078`), `/health` + `/ready` (`internal/api/router.go:247,1436`). No pprof registered. + +### Dependencies +- `go 1.26.4` → `slog.MultiHandler` is available in the stdlib (no `samber/slog-multi` needed). +- Already present: `github.com/prometheus/client_golang v1.23.2`; `go.opentelemetry.io/otel/sdk/metric v1.41.0` is present only as an **indirect** transitive dep (not wired). No `go.opentelemetry.io/otel` core, no trace SDK, no exporters, no otelslog/otelhttp/otelgrpc. + +--- + +## File Structure + +### New files +- `internal/telemetry/telemetry.go` — SDK bootstrap: `Config` (parsed from env), `Setup(ctx, Config) (*Providers, shutdown func(context.Context) error, error)`, shared `resource.Resource`, `TracerProvider`, `LoggerProvider`, W3C propagator, batch processors. +- `internal/telemetry/config.go` — `OTEL_*` + `SILO_OTEL_*` env parsing (enable gate, endpoint, protocol, sampler, service name/version). +- `internal/telemetry/loghandler.go` — a best-effort wrapper around `otelslog.Handler` whose `Handle` never propagates an OTel export error (mirrors the `logsink` best-effort contract), plus the fan-out assembly helper. +- `internal/telemetry/httpclient.go` — shared `otelhttp.NewTransport`-wrapped `http.RoundTripper` factory for outbound clients. +- `internal/telemetry/telemetry_test.go`, `internal/telemetry/config_test.go`, `internal/telemetry/loghandler_test.go`. + +### Modified files (by phase) +- `go.mod` / `go.sum` — add OTel core/sdk/exporters + contrib instrumentation + pgx/redis tracers. +- `cmd/silo/main.go` — call `telemetry.Setup`, insert otelslog fan-out into `buildBaseHandler`, defer `shutdown` in `run()`. +- `internal/api/router.go` — `otelhttp` handler/middleware in the chi chain; inject `trace_id`/`span_id` into request-logger attrs. +- `internal/api/middleware/request_logger.go` — add trace/span IDs to the emitted attrs. +- `internal/jellycompat/router.go`, `internal/transcodenode/server.go` — `otelhttp` server instrumentation + propagation. +- `internal/database/postgres.go` — `otelpgx` `QueryTracer` on the pool config. +- `internal/cache/redis.go` — `redisotel.InstrumentTracing` on both client constructions. +- Outbound-client packages listed above — accept/use the shared traced transport. +- `internal/pluginhost/host.go` (+ client dial sites) — `otelgrpc` server/client stats handlers. +- `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go` — domain spans. +- **All packages under `internal/`** — call-site sweep (`slog.* → slog.*Context`, `component` attr). Batched per-package in Phase 3. +- `docs/architecture/observability.md` — operator docs (env vars, collector wiring, what is/isn't instrumented). + +--- + +## Phasing overview + +- **Phase 0** — Dependencies + `internal/telemetry` SDK bootstrap (no behavior change; disabled unless configured). +- **Phase 1** — Logs bridge fan-out (logs export works end-to-end; call sites unchanged). +- **Phase 2** — Tracing providers + propagation plumbing (spans exist, exported). +- **Phase 3** — Full call-site sweep to `…Context` + `component` attrs (unlocks `trace_id` in logs). +- **Phase 4** — HTTP server + client tracing seams. +- **Phase 5** — pgx + Redis tracing seams. +- **Phase 6** — Plugin gRPC host-side propagation. +- **Phase 7** — Domain spans (scanner / playback / taskmanager). +- **Phase 8** — Shutdown wiring, docs, verification hardening. + +Each phase is independently shippable and leaves `main` green. Phases 1–2 are prerequisites for Phase 3's payoff; Phases 4–7 can land in any order after Phase 2. + +--- + +## Prometheus / Grafana coexistence + +**Question this answers:** does adopting OTel break, replace, or entangle the existing Prometheus + Grafana metrics? **Answer: no — they stay separate rails, provided one footgun is guarded. Metrics stay on Prometheus permanently for this plan; migrating them to OTel is not in scope.** + +- **Separate lines, not merged (this plan).** OTel `telemetry.Setup` installs **only** a `TracerProvider` and a `LoggerProvider`. It **must not** call `otel.SetMeterProvider` (Task 1 Step 5 builds no MeterProvider). Prometheus keeps its own independent rail: `client_golang` registry → `/metrics` (`cmd/silo/main.go:2078`) → Grafana scrape. Nothing about the metrics path changes. Grafana metric dashboards are untouched; traces become a *new, additive* Grafana/Tempo capability, not a change to existing panels. +- **THE FOOTGUN — global MeterProvider (guard required).** The OTel instrumentation libraries used for traces (`otelhttp`, `otelgrpc`, `otelpgx`, `redisotel`) **also emit metrics by default**, through `otel.GetMeterProvider()`. Because this plan never sets a MeterProvider, that global stays the built-in **no-op** — so those metric calls are silently discarded: no double-counting into Prometheus, no second `/metrics` source, no crash, no measurable overhead. This is safe *by omission*, so make it **explicit and tested**, not incidental: + - In each instrumentation call, prefer passing an explicit no-op meter provider option where the library supports it (e.g. `otelhttp.WithMeterProvider(noop.NewMeterProvider())`) so a future accidental `SetMeterProvider` can't silently start double-emitting HTTP/DB metrics. + - Add a guard test asserting `otel.GetMeterProvider()` is the no-op after `telemetry.Setup` (i.e. scope B did not wire metrics). + - HTTP request metrics continue to come **solely** from the existing `apimw.Metrics` Prometheus middleware (`internal/api/middleware/metrics.go:35`); `otelhttp` contributes **traces only**. +- **Metrics remain fully on Prometheus.** Migrating metrics to the OTel metrics SDK is explicitly out of scope and not planned here — the current `client_golang` instrumentation is retained as-is. + +## Blast radius & complexity + +Grounded in actual counts (`grep` over `internal/` + `cmd/`): **173 files** contain `slog.*` calls totalling **1,339 call sites** (39 already on the `…Context` variant → **~1,300 to convert**). Total change spans **~185–190 files** and **~2,500–3,200 diff lines**. + +Complexity is **concentrated, not spread** — line count is a poor proxy for risk here: + +| Phase group | Files | ~LOC | Nature | Risk | +|---|---|---|---|---| +| **Phase 0–2** — telemetry bootstrap, logs fan-out bridge, tracing providers + propagation | ~10–12 (mostly new `internal/telemetry/*` + `cmd/silo/main.go`) | ~900–1,100 net-new | Genuinely new code | **High** — the level-gated fan-out, provider lifecycle, shutdown flush | +| **Phase 3** — call-site sweep | 173 | ~1,300–1,700 modified | Mechanical, codemod-assisted, per-package-green | **Low per file** — a bad conversion fails the build, not runtime; the burden is review volume, not engineering | +| **Phase 4–7** — HTTP/pgx/redis/gRPC/client + domain spans | ~12–15 | ~350–500 net-new | Small, localized seams | **Medium** — ~4 seams are subtly easy to get wrong (gRPC broker-vs-real channel, per-session playback span, chi route timing, struct-bound loggers) | +| **Phase 8** — shutdown wiring, docs | ~2 | ~170 | Wiring + prose | Low | + +**Takeaway:** ~90% of the *file count* (the 173-file sweep) is the *safe* part; ~90% of the *risk* lives in the few hundred lines of `internal/telemetry` plus ~4 seams. The scary number (1,300 sites) is mechanical churn. + +### Commit strategy + +- **Commit 1 — Phases 0–2** (the observability capability itself): telemetry bootstrap + level-gated logs fan-out + tracing providers/propagation. Small, high-value, reviewable as a unit; delivers OTLP log/trace export end-to-end without touching call sites. +- **Commit 2 — Phase 3** (the call-site sweep, in isolation): the ~1,300-site `…Context` + `component` conversion across 173 files, as its own commit so the large mechanical diff never obscures the load-bearing logic in Commit 1. (May itself be split per-package internally, but lands as a distinct changeset from Commit 1.) +- **Later commits — Phases 4–8**: seam tracing + domain spans + shutdown/docs, each landing independently after the foundation is in. + +--- + +## Task 1: Add dependencies and the telemetry SDK bootstrap + +**Files:** +- Modify: `go.mod`, `go.sum` +- Create: `internal/telemetry/config.go`, `internal/telemetry/config_test.go`, `internal/telemetry/telemetry.go`, `internal/telemetry/telemetry_test.go` + +- [ ] **Step 1: Add dependencies.** `go get go.opentelemetry.io/otel go.opentelemetry.io/otel/sdk go.opentelemetry.io/otel/sdk/trace go.opentelemetry.io/otel/sdk/log go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp go.opentelemetry.io/contrib/bridges/otelslog`. Run `go mod tidy`. +- [ ] **Step 2: Write failing config tests** (`config_test.go`) covering: disabled by default (no endpoint, no `SILO_OTEL_ENABLED`); enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` set; `service.name` from `OTEL_SERVICE_NAME` overriding a default of `silo-server`; protocol selection (`grpc` default / `http/protobuf`); sampler default (`parentbased_always_on`) and ratio override; invalid numeric sampler arg falls back to default (not a crash). +- [ ] **Step 3: Implement `config.go`.** Parse `OTEL_*` (respect the OTel env spec) plus a `SILO_OTEL_ENABLED` convenience gate. Default `service.name=silo-server`, `service.version` from build info, `node.id` from the existing `SILO_NODE_NAME`/nodeID signal. Return a fully-defaulted `Config` with an `Enabled bool`. +- [ ] **Step 4: Write failing telemetry test** (`telemetry_test.go`): `Setup` with `Enabled=false` returns a no-op shutdown and installs nothing; `Setup` with a `console`/`none` exporter builds providers and a shutdown that returns nil; shutdown is idempotent. +- [ ] **Step 5: Implement `telemetry.go`.** Follow the canonical `setupOTelSDK` shape: build one shared `resource.Resource` (`resource.WithAttributes(semconv.ServiceName(cfg.ServiceName))`, `WithFromEnv`, `WithProcess`, `WithHost`), a composite W3C `TraceContext{}+Baggage{}` propagator, a `TracerProvider` (`trace.WithBatcher`), and a `LoggerProvider` (`log.WithProcessor(log.NewBatchProcessor(...))`). Register `otel.SetTracerProvider`, `otel.SetTextMapPropagator`, `global.SetLoggerProvider`. **Do NOT build or register a MeterProvider** — metrics stay on Prometheus (see "Prometheus / Grafana coexistence"); leaving `otel.GetMeterProvider()` as the built-in no-op is what keeps the trace instrumentation libraries from double-emitting metrics. Accumulate `shutdownFuncs` and join their errors. When `Enabled=false`, return early with a no-op shutdown so the entire feature is dormant unless configured. +- [ ] **Step 6: Verify.** `go build ./...`, `go vet ./internal/telemetry/`, `go test ./internal/telemetry/ -race`, `gofmt -l` clean. + +## Task 2: Logs bridge — fan-out otelslog into the handler chain + +**Files:** +- Create: `internal/telemetry/loghandler.go`, `internal/telemetry/loghandler_test.go` +- Modify: `cmd/silo/main.go` + +- [ ] **Step 1: Write failing tests** (`loghandler_test.go`): a fan-out handler forwards a record to both a capturing stderr-substitute handler and an otelslog handler backed by an in-memory `LoggerProvider`; an OTel-branch `Handle` error is swallowed and does **not** prevent the console branch from receiving the record (mirrors the `logsink` best-effort contract that keeps `opslog`'s DB path alive — see `internal/opslog/handler.go` "inner error skips capture" behavior). +- [ ] **Step 2: Implement `loghandler.go`.** Provide `NewOTelHandler(lp) slog.Handler` = `otelslog.NewHandler("silo-server", otelslog.WithLoggerProvider(lp))` wrapped so its `Handle`/`WithAttrs`/`WithGroup` never propagate export errors. Provide `FanOut(console, otel slog.Handler) slog.Handler` returning `slog.NewMultiHandler(console, otelBestEffort)` (stdlib, Go 1.26). +- [ ] **Step 2a (REQUIRED — level gating): gate the OTel branch by the shared `LevelVar`.** `slog.MultiHandler.Enabled` returns true if **any** child is enabled. The stderr child is gated by the shared `*slog.LevelVar` (`cmd/silo/main.go:542-543`), but otelslog's `Enabled` follows the (unfiltered-by-default) `LoggerProvider`. Left unfixed, the logger would evaluate `Enabled==true` for `Debug` at `LogLevel=info` — constructing + dispatching Debug records it previously skipped across ~1,200 sites (per-call allocation, violates "Performance first") **and** exporting Debug to OTLP while stderr stays silent (a confusing, silent divergence). Wrap `NewOTelHandler` in a level-gate bound to the same `logLevelVar` (or an equivalent `otelslog` level-bridge option) so console and OTLP share exactly one verbosity knob. Add a test asserting a Debug record is dropped by both branches when the shared level is `info`. +- [ ] **Step 3: Wire into `buildBaseHandler`** (defined `cmd/silo/main.go:132-138`, **called at `cmd/silo/main.go:543`** inside `run()` after the pool is up). When telemetry is enabled, return `FanOut(stderrHandler, levelGated(NewOTelHandler(lp)))` instead of the bare stderr handler; otherwise return today's stderr handler unchanged. The `logfilter` (`:544`) → `opslog` (`:209`, installed `:567`) wrapping above stays exactly as-is, so quiet-filtering and DB capture are preserved and OTel mirrors the console stream (document this mirror behavior, matching the prior file-sink decision). Ordering is safe: `telemetry.Setup` depends only on `OTEL_*` env (not the DB), so call it earlier in `run()` and thread the `LoggerProvider` into `buildBaseHandler` — the provider is available well before `:543`. +- [ ] **Step 3a (coverage caveat): early-boot logs are not exported.** Many `slog` calls fire before the fan-out is installed at `:543` — DB connect (`:389`), migrations (`:423`,`:441`), secret backfill (`:287-305`), auto-tuning (`:223-272`), and everything inside `database.NewPool`. These reach only Go's default stderr handler today (not even opslog), so OTel not capturing them is **not** a regression — but it means "logs export end-to-end" excludes boot. Either document this gap in the observability doc, or (if boot logs matter to operators) move `telemetry.Setup` + a minimal OTel-only tee to the very top of `run()`. +- [ ] **Step 4: Verify** logs actually export. Bring up a local collector (`otel/opentelemetry-collector` with a `debug`/`logging` exporter, documented in Task 10) or use the `console` log exporter; run the server, confirm `slog.Info` records appear on both stderr and the OTLP/console sink. Run `go build ./...`, `go test ./cmd/silo/ ./internal/telemetry/`. + +## Task 3: Full call-site sweep — `…Context` variants + `component` attrs + +This is the largest task. Batch per-package; each batch is its own commit and must keep the build green. The goal state: every `slog.Info/Warn/Error/Debug` becomes the `…Context(ctx, …)` variant wherever a `ctx` is in scope, and the `"subsystem: message"` prefix convention is replaced by an explicit `slog.String("component", "")` attr. `opslog.InferComponent` stays as the fallback for any residual prefix-only calls (backward compatible — do **not** remove it). + +- [ ] **Step 1: Establish the pattern + guardrail.** In a heavily-used package (start with `internal/scanner`), convert calls to `slog.InfoContext(ctx, "msg", slog.String("component", "scanner"), …)`. Confirm `opslog` still classifies correctly (component attr wins over inference — `internal/opslog/handler.go:51-54`). Prototype the `sloglint` rule locally to sanity-check the target form, but do **not** enable it repo-wide yet — it is turned on at `error` severity only as the closing step of the sweep (Task 9), since enabling it before all ~1,300 sites convert turns CI red. +- [ ] **Step 2: Codemod the mechanical conversions.** Use an AST-based rewrite (`gofmt -r` is insufficient for adding a `ctx` arg) — a small `golang.org/x/tools/go/analysis` or `astutil` script that, for each `slog.Info(...)` call inside a function with a `context.Context` in scope, rewrites to `slog.InfoContext(ctx, ...)`. Calls with no reachable `ctx` are left on the non-context variant and flagged for manual review (background init, `main`, top-level goroutines). Review every rewrite — do not blind-commit the codemod. +- [ ] **Step 3: Convert per-package, hottest first**, one commit each, `go build`/`go test` between: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35), then the long tail. For struct-bound `.With("component", …)` loggers (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `request_logger.go`, `taskmanager.New(logger *slog.Logger)` at `internal/taskmanager/manager.go:25`), keep the bound `component` and migrate emit calls to the context variants. **Caveat (corrects the "no structural re-plumbing" claim in Validated Findings):** many such structs log from methods that do **not** currently take a `ctx`. For those, either thread a `ctx` parameter through the method signatures (structural change — budget for it) or, where a request/operation ctx genuinely isn't reachable, leave the call on the non-context variant (still captured via fan-out, just without `trace_id`). Span-context propagation is free *only where a `ctx` already flows*; the struct-bound method surfaces are the exception, not the rule. +- [ ] **Step 4: Normalize component names.** Produce a canonical component list (jellycompat, playback, api, scanner, metadata, notifications, catalog, auth, recommendations, adminjob, taskmanager, …) and apply it consistently so trace/log/DB classification agree. Document the list in the package doc. +- [ ] **Step 5: Verify.** `go build ./...`, `go vet ./...`, full `go test ./...`, `gofmt -l` clean, the new sloglint guard passes repo-wide. Spot-check that `opslog` entries still populate `component` after the sweep. + +## Task 4: Tracing — HTTP server middleware + request-logger correlation + +**Files:** +- Modify: `internal/api/router.go`, `internal/api/middleware/request_logger.go`, `internal/jellycompat/router.go`, `internal/transcodenode/server.go` + +- [ ] **Step 1: Add deps.** `go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`; `go mod tidy`. +- [ ] **Step 2: Instrument the main router.** Insert `otelhttp` server handling into the chi chain right after `middleware.RequestID` (`internal/api/router.go:213`) and before `apimw.RequestLogger`, so a server span exists for the whole handler and `request_logger` can read it. **Route-tag timing:** chi resolves the route *after* the middleware stack runs, so `http.route` cannot be set at middleware entry — set it post-routing from `chi.RouteContext(r).RoutePattern()`, and commit to `sanitizePath` (`metrics.go:79-89`) on the raw path as the fallback when no pattern is available. Exclude `/health`/`/ready` like the logger already does (`request_logger.go:22`). Note `activitylog.NewMiddleware` (`internal/api/router.go:230`) is also request-scoped and may want the span/`trace_id` — evaluate wiring it in the same pass. +- [ ] **Step 3: Correlate logs.** In `request_logger.go:53-70`, pull the active span from `r.Context()` and add `slog.String("trace_id", …)`/`slog.String("span_id", …)` to the emitted attrs. (Once Task 3 lands and handlers log with `ctx`, the otelslog bridge attaches these automatically to every downstream record too — this step guarantees the access-log line itself carries them.) +- [ ] **Step 4: Instrument jellycompat + transcode-node servers** the same way (`internal/jellycompat/router.go:30`, `internal/transcodenode/server.go`), ensuring incoming W3C `traceparent` headers continue the trace across processes. +- [ ] **Step 5: Verify.** Drive a request through a local collector; confirm one server span per request with `http.route`, `trace_id` present in the access log, and a continued trace when hitting a transcode-node endpoint. + +## Task 5: Tracing — outbound HTTP clients (shared traced transport) + +**Files:** +- Create: `internal/telemetry/httpclient.go` +- Modify: `internal/nodepool/health.go`, metadata/subtitle provider packages, `internal/plugins` HTTP proxy + +- [ ] **Step 1: Shared factory.** Implement `telemetry.NewHTTPTransport(base http.RoundTripper) http.RoundTripper` = `otelhttp.NewTransport(base)` with sane span-name/route options. This satisfies CLAUDE.md's "avoid duplicate logic" — one wrapper, injected everywhere, not per-package patches. +- [ ] **Step 2: Inject** the traced transport into the ad-hoc `http.Client` literals: `internal/nodepool/health.go:22-26` and other nodepool clients, `internal/metadata/tmdb`, `internal/metadata/trakt`, `internal/metadata/translation`, `internal/mdblist`, `internal/subtitles/{opensubtitles,subdl,subsource}`, and `internal/plugins.HTTPProxy` (`router.go:149`). Prefer constructing the transport once in `cmd/silo/main.go` and passing it down over editing each package's client construction independently where a seam exists. +- [ ] **Step 3: Verify.** A play/scan flow that calls a metadata provider and a transcode node shows child client spans nested under the originating server span, with propagated `traceparent`. + +## Task 6: Tracing — pgx pool + Redis + +**Files:** +- Modify: `internal/database/postgres.go`, `internal/cache/redis.go`, `go.mod` + +- [ ] **Step 1: pgx tracer.** `go get github.com/exaring/otelpgx`; set `poolCfg.ConnConfig.Tracer = otelpgx.NewTracer(...)` on the config returned by `pgxpool.ParseConfig` (`internal/database/postgres.go:14`), guarded on telemetry enabled. Zero call-site changes — instruments every query. Use `otelpgx` options to avoid logging full SQL args if that is a concern. +- [ ] **Step 2: Redis hook.** `go get github.com/redis/go-redis/extra/redisotel/v9`; call `redisotel.InstrumentTracing(client)` after **both** `redis.NewClient` constructions (`internal/cache/redis.go:145-254`). +- [ ] **Step 3: Verify.** A request that hits Postgres and Redis shows DB and cache child spans under the server span. `go build ./...`, `go test ./internal/database/ ./internal/cache/`. + +## Task 7: Tracing — host-side plugin gRPC propagation + +**Files:** +- Modify: `internal/pluginhost/host.go`, plugin gRPC client dial sites, `go.mod` + +- [ ] **Step 1: Add dep.** `go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`. +- [ ] **Step 2: Find the real invocation channel FIRST.** `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` sits inside `broker.AcceptAndServe(...)` — a hashicorp/go-plugin **brokered sub-stream** (host services exposed back to the plugin), *not* the primary host→plugin RPC channel (go-plugin stands that up internally; silo does not construct it at this line). Instrumenting only `:336` would capture the reverse/broker channel and **miss primary plugin invocations**. Identify where the primary plugin client connection is created/dialed (the load-bearing path) before instrumenting. +- [ ] **Step 3: Client side (load-bearing).** Add `grpc.WithStatsHandler(otelgrpc.NewClientHandler())` to the host→plugin dial that carries actual invocations (dial pattern per `internal/pluginhost/client_test.go:18`). This is the half that must cover the real call path. +- [ ] **Step 4: Server side.** Add `grpc.StatsHandler(otelgrpc.NewServerHandler())` to `grpc.NewServer(...)` at `:336` for the brokered channel — useful but secondary. +- [ ] **Step 5: Note the boundary.** The plugin-subprocess side that receives `traceparent` is owned by `silo-plugin-sdk` — record a coordination follow-up; do **not** attempt SDK-side changes here. +- [ ] **Step 6: Verify against a real plugin call.** Confirm an actual plugin invocation (not just a broker sub-stream) produces a gRPC client span; full end-to-end continuation into the plugin process is deferred to the SDK follow-up. + +## Task 8: Domain spans — scanner, playback, taskmanager + +**Files:** +- Modify: `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go` + +- [ ] **Step 1: Scanner.** Start a span per scan run in `applyScopedScan` (`internal/scanner/scanner.go:1287`) and, if not too chatty, a child per file. Attach library/scope attrs. +- [ ] **Step 2: Playback.** Do **not** span `NewSessionManager` (`internal/playback/session.go:152`) — that is the singleton manager constructor, called once at startup; a span there covers the whole process lifetime and cannot carry per-session attrs. Instead attach the span at the actual per-session create/start path (where a `*Session` is added to the `sessions` map), spanning start→stop with `play_method`/session-id attrs, so it links to the transcode-node client spans (Task 5) for end-to-end session traces. +- [ ] **Step 3: Taskmanager.** Wrap `RunTask` (`internal/taskmanager/manager.go:231`) in a per-run span; these background goroutines start new root traces (no incoming context), so create a fresh root span with baggage identifying the task. +- [ ] **Step 4: Verify.** A scan and a playback session each produce a coherent trace tree; background task runs appear as their own traces. + +## Task 9: Durable enforcement — keep future code (human + AI) on the standard + +The sweep is worthless if new code drifts back to context-less `slog.Info(...)`. Lock the standard in machine-enforced form so it holds without relying on reviewer vigilance. **Sequencing: lands together with the Phase-3 sweep in the same commit, after the legacy sites are converted — enabling it earlier turns CI red repo-wide.** + +**Files:** +- Modify: `.golangci.yml` +- Reference: `docs/architecture/observability.md` (from Task 10) + +- [x] **Step 1: Machine gate (the real guarantee) — add `sloglint` to `.golangci.yml`.** Added to `linters.enable` with all four rules configured: `context: "scope"`, `static-msg: true`, `key-naming-case: snake`, `no-mixed-args: true`. Since `make lint` runs `golangci-lint run` (`Makefile:54`), this blocks any PR — human- or AI-authored — that regresses the call form. Verified via standalone `sloglint`: after the sweep all four rules report **0 violations** repo-wide (production and test), so no `_test.go` exclusion was needed. (`key-naming-case` and `no-mixed-args` were already clean pre-sweep; `static-msg` had 6 sites, fixed in the sweep.) +- [x] **Step 2: Document the honest gap.** Recorded in `docs/architecture/observability.md`: `sloglint` enforces the *shape* (`…Context`, snake keys, static msg) but **cannot** enforce that a `component` attr is present. That convention rests on the observability doc, the canonical component list, and review. +- **Note (CLAUDE.md subsection dropped):** per maintainer direction, no Logging subsection is added to `CLAUDE.md`. The post-sweep codebase already models the idiom everywhere and the `sloglint` gate enforces it, so the standard holds without a CLAUDE.md entry; `docs/architecture/observability.md` carries the human/LLM-facing rationale and canonical list. + +## Task 10: Shutdown wiring, docs, and final verification + +**Files:** +- Modify: `cmd/silo/main.go` +- Create: `docs/architecture/observability.md` + +- [ ] **Step 1: Graceful shutdown.** `defer shutdown(ctx)` from `telemetry.Setup` in `run()`, integrated with the existing signal handler, using a **generous** timeout so batch processors flush (buffered spans/logs are lost otherwise). Ensure it runs before process exit on every path. +- [ ] **Step 2: Docs.** Write `docs/architecture/observability.md`: the `OTEL_*`/`SILO_OTEL_ENABLED` surface, default-off behavior, a local `docker compose` collector example (OTLP in → debug/Loki/Tempo out), the canonical component list, and the documented non-goals/risks (metrics stay Prometheus; logs unredacted at the OTLP sink; quiet-filter also suppresses OTel logs; plugin-SDK side is a follow-up). +- [ ] **Step 3: Full verification.** `go build ./...`; `go vet ./...`; `go test ./... -race` (at least the touched packages under `-race`); `gofmt -l` clean; `cd web && pnpm run lint && pnpm run format:check` (no-op for this backend change but part of the MR gate); `make verify-local-paths`. Manual: default-off start (no OTel env) behaves exactly as today; enabled start exports logs+traces to a local collector; a single request produces correlated log `trace_id` + a server→DB→cache→client span tree. **Metrics coexistence:** with OTel enabled, confirm `/metrics` still serves the full existing Prometheus set (HTTP + domain metrics), Grafana scraping is unaffected, and the guard test confirms `otel.GetMeterProvider()` is the no-op (no OTel metrics rail wired). + +--- + +## Risk / follow-ups + +- **Level-gating is load-bearing (see Task 2 Step 2a).** `slog.MultiHandler.Enabled` is an OR across children; without gating the OTel branch by the shared `LevelVar`, Debug records get allocated + OTLP-exported at `level=info`. Treated as a required step, not optional. +- **Early-boot logs (pre-`:543`) are not exported** — matches existing opslog behavior, documented not fixed (Task 2 Step 3a). +- **Playback span must attach per-session, not at the manager constructor**, and **plugin gRPC instrumentation must target the primary invocation channel, not just the broker sub-stream** — both are easy to get wrong (Tasks 8/7). +- **Call-site sweep is a large, review-heavy diff.** Mitigated by per-package batching, an AST codemod with mandatory human review, and a `sloglint` guardrail. Background/init calls with no `ctx` legitimately stay on the non-context variants. +- **Quiet-filter also suppresses OTel logs** (the fan-out sits at the base, below the filter). This mirrors stderr exactly — intended, but if durable-capture-despite-quiet is wanted, the OTel branch must tee in above `logfilter`. Deferred, product call. +- **Redaction is key-based** (handled via `internal/logredact` on all sinks): secrets under a recognized key are masked, but a secret in a free-text message or under an unrecognized key is not caught. +- **Metrics remain on Prometheus** by explicit non-goal; dual observability backends until a future OTel-metrics plan. +- **Plugin-subprocess trace continuation** needs a coordinated `silo-plugin-sdk` change; host-side only here. +- **Sampler config in multi-node deploys** must use `parentbased_traceidratio` (not bare `traceidratio`) so transcode/proxy nodes respect the parent decision — documented in Task 10. +- **Batch-processor flush on shutdown** is critical; verify no lost telemetry on redeploy/signal paths. + +## Verification summary (gate before MR) + +- `go build ./...`, `go vet ./...`, `go test ./... -race` (touched packages), `gofmt -l` clean. +- `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`. +- Manual: default-off parity; enabled logs+traces to a local collector; correlated `trace_id` in logs; end-to-end span tree across HTTP → pgx → redis → outbound client → gRPC. diff --git a/go.mod b/go.mod index 78ea97b9..a92be25d 100644 --- a/go.mod +++ b/go.mod @@ -47,13 +47,17 @@ require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dunglas/httpsfv v1.1.0 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gookit/color v1.5.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect @@ -92,8 +96,14 @@ require ( github.com/zishang520/engine.io/v2 v2.5.0 // indirect github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect github.com/zishang520/webtransport-go v0.9.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) @@ -120,6 +130,15 @@ require ( github.com/prometheus/procfs v0.20.1 // indirect github.com/robfig/cron/v3 v3.0.1 github.com/sony/sonyflake v1.3.0 + go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/log v0.20.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/sdk/log v0.20.0 go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/sync v0.21.0 diff --git a/go.sum b/go.sum index fb522141..7cbd552b 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/bytecodealliance/wasmtime-go/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcOlzmR6Ur6wjLRIQOhTQ= github.com/bytecodealliance/wasmtime-go/v44 v44.0.0/go.mod h1:GP93piU+39CoFVCQ5xfHrPOUtL0APlMnkbblJ2d3YY0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -82,6 +84,7 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -111,6 +114,8 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg= github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -301,16 +306,36 @@ go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk= go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -413,6 +438,8 @@ golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= diff --git a/internal/activitylog/cleanup.go b/internal/activitylog/cleanup.go index 51f324fc..01d92291 100644 --- a/internal/activitylog/cleanup.go +++ b/internal/activitylog/cleanup.go @@ -71,35 +71,35 @@ func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, p cutoff := time.Now().UTC().AddDate(0, 0, -days) if pm != nil { if err := pm.EnsureFuturePartitions(ctx); err != nil { - slog.Warn("activitylog ensure future partitions error", "error", err) + slog.WarnContext(ctx, "activitylog ensure future partitions error", "component", "activitylog", "error", err) } partitionCleanupFailed := false totalDeleted := int64(0) if dropped, err := pm.DropExpiredPartitions(ctx, cutoff); err != nil { - slog.Warn("activitylog partition cleanup error", "error", err) + slog.WarnContext(ctx, "activitylog partition cleanup error", "component", "activitylog", "error", err) partitionCleanupFailed = true } else if len(dropped) > 0 { - slog.Info("activitylog dropped expired partitions", "partitions", dropped) + slog.InfoContext(ctx, "activitylog dropped expired partitions", "component", "activitylog", "partitions", dropped) } if deleted, err := pm.DeleteExpiredRowsFromDefault(ctx, cutoff); err != nil { - slog.Warn("activitylog default partition cleanup error", "error", err) + slog.WarnContext(ctx, "activitylog default partition cleanup error", "component", "activitylog", "error", err) partitionCleanupFailed = true } else if deleted > 0 { totalDeleted += deleted - slog.Info("activitylog default partition cleanup completed", "deleted", deleted, "retention_days", days) + slog.InfoContext(ctx, "activitylog default partition cleanup completed", "component", "activitylog", "deleted", deleted, "retention_days", days) } if !partitionCleanupFailed { return totalDeleted } - slog.Warn("activitylog partition cleanup degraded, falling back to row deletes", "retention_days", days) + slog.WarnContext(ctx, "activitylog partition cleanup degraded, falling back to row deletes", "component", "activitylog", "retention_days", days) } total := deleteExpiredRowsBefore(ctx, pool, cutoff) if total > 0 { - slog.Info("activitylog cleanup completed", "deleted", total, "retention_days", days) + slog.InfoContext(ctx, "activitylog cleanup completed", "component", "activitylog", "deleted", total, "retention_days", days) } return total } @@ -116,7 +116,7 @@ func deleteExpiredRowsBefore(ctx context.Context, pool *pgxpool.Pool, cutoff tim ) `, cutoff, cleanupBatchSize) if err != nil { - slog.Warn("activitylog cleanup error", "error", err) + slog.WarnContext(ctx, "activitylog cleanup error", "component", "activitylog", "error", err) return total } deleted := result.RowsAffected() diff --git a/internal/activitylog/consumer.go b/internal/activitylog/consumer.go index b0bd3f83..a4c272f1 100644 --- a/internal/activitylog/consumer.go +++ b/internal/activitylog/consumer.go @@ -69,14 +69,14 @@ func (c *Consumer) drainRedis(ctx context.Context) { for { entries, err := c.popRedisBatch(ctx) if err != nil { - slog.Warn("activitylog: Redis pop error", "error", err) + slog.WarnContext(ctx, "activitylog: Redis pop error", "component", "activitylog", "error", err) return } if len(entries) == 0 { return } if err := c.insertBatchWithRetry(ctx, entries); err != nil { - slog.Error("activitylog: batch insert failed after retries, dropping batch", + slog.ErrorContext(ctx, "activitylog: batch insert failed after retries, dropping batch", "component", "activitylog", "error", err, "count", len(entries)) } } @@ -95,7 +95,7 @@ func (c *Consumer) popRedisBatch(ctx context.Context) ([]LogEntry, error) { for _, raw := range result { var entry LogEntry if err := json.Unmarshal([]byte(raw), &entry); err != nil { - slog.Warn("activitylog: skipping malformed entry", "error", err) + slog.WarnContext(ctx, "activitylog: skipping malformed entry", "component", "activitylog", "error", err) continue } entries = append(entries, entry) @@ -108,7 +108,7 @@ func (c *Consumer) insertBatchWithRetry(ctx context.Context, entries []LogEntry) for attempt := 0; attempt < c.maxRetries; attempt++ { if err := c.insertBatch(ctx, entries); err != nil { lastErr = err - slog.Warn("activitylog: batch insert attempt failed", + slog.WarnContext(ctx, "activitylog: batch insert attempt failed", "component", "activitylog", "attempt", attempt+1, "error", err) time.Sleep(time.Duration(attempt+1) * time.Second) continue @@ -131,7 +131,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) { case <-ctx.Done(): if len(batch) > 0 { if err := c.insertBatch(context.Background(), batch); err != nil { - slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err) } } return @@ -139,7 +139,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) { if !ok { if len(batch) > 0 { if err := c.insertBatch(context.Background(), batch); err != nil { - slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err) } } return @@ -147,14 +147,14 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan LogEntry) { batch = append(batch, entry) if len(batch) >= c.batchSize { if err := c.insertBatch(ctx, batch); err != nil { - slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err) } batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { if err := c.insertBatch(ctx, batch); err != nil { - slog.Warn("activity log batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "activity log batch insert failed", "component", "activitylog", "entries", len(batch), "error", err) } batch = batch[:0] } @@ -220,7 +220,7 @@ func (c *Consumer) insertBatch(ctx context.Context, entries []LogEntry) error { for _, entry := range inserted { if err := c.streamHub.PublishAppend(ctx, logstream.StreamAudit, entry); err != nil { - slog.Warn("activitylog: failed to publish log stream append", "error", err, "id", entry.ID) + slog.WarnContext(ctx, "activitylog: failed to publish log stream append", "component", "activitylog", "error", err, "id", entry.ID) } } diff --git a/internal/adminjob/image_cache_cleanup.go b/internal/adminjob/image_cache_cleanup.go index 3e881ff2..cca6b440 100644 --- a/internal/adminjob/image_cache_cleanup.go +++ b/internal/adminjob/image_cache_cleanup.go @@ -65,7 +65,7 @@ func (e *ImageCacheCleanupExecutor) Execute( } n, err := e.s3.DeletePrefix(ctx, bucket, prefix) if err != nil { - slog.Warn("image cache cleanup: s3 delete failed", "prefix", prefix, "error", err) + slog.WarnContext(ctx, "image cache cleanup: s3 delete failed", "component", "adminjob", "prefix", prefix, "error", err) continue } deletedPrefixes++ diff --git a/internal/adminjob/runner.go b/internal/adminjob/runner.go index 31fdc6d5..2b0acb87 100644 --- a/internal/adminjob/runner.go +++ b/internal/adminjob/runner.go @@ -261,7 +261,7 @@ func (r *Runner) queueImageCacheCleanup(ctx context.Context, createdByUserID int Message: "Queued cached image cleanup", }) if err != nil { - slog.Warn("admin jobs: failed to queue image cache cleanup", + slog.WarnContext(ctx, "admin jobs: failed to queue image cache cleanup", "component", "adminjob", "library_id", result.LibraryID, "library_name", result.LibraryName, "error", err, @@ -782,7 +782,7 @@ func (r *Runner) heartbeatLoop(ctx context.Context, jobID string, stop <-chan st return case <-ticker.C: if err := r.repo.TouchHeartbeat(ctx, jobID); err != nil && !errors.Is(err, ErrJobNotFound) { - slog.Warn("admin jobs: failed to touch heartbeat", "job_id", jobID, "error", err) + slog.WarnContext(ctx, "admin jobs: failed to touch heartbeat", "component", "adminjob", "job_id", jobID, "error", err) } } } @@ -823,7 +823,7 @@ func (r *Runner) publishJobByID(ctx context.Context, eventType notifications.Typ job, err := r.repo.GetByID(ctx, id) if err != nil { if !errors.Is(err, ErrJobNotFound) { - slog.Warn("admin jobs: failed to load job for realtime event", "job_id", id, "error", err) + slog.WarnContext(ctx, "admin jobs: failed to load job for realtime event", "component", "adminjob", "job_id", id, "error", err) } return } @@ -836,7 +836,7 @@ func (r *Runner) publishJob(ctx context.Context, eventType notifications.Type, j return } if err := r.realtimeHub.PublishJob(ctx, eventType, job); err != nil { - slog.Warn("admin jobs: failed to publish realtime job event", + slog.WarnContext(ctx, "admin jobs: failed to publish realtime job event", "component", "adminjob", "job_id", job.ID, "type", eventType, "error", err, diff --git a/internal/ai/llm/client.go b/internal/ai/llm/client.go index 689bed2f..6fb9c94e 100644 --- a/internal/ai/llm/client.go +++ b/internal/ai/llm/client.go @@ -163,7 +163,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) return fmt.Errorf("chat API error: %s", parsed.Error.Message) } if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" { - slog.Warn("AI chat returned no choices, retrying", + slog.WarnContext(ctx, "AI chat returned no choices, retrying", "component", "ai", "model", cfg.ChatModel, "response_bytes", len(respBody)) return fmt.Errorf("chat API returned no choices") } @@ -231,7 +231,7 @@ func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label switch { case resp.StatusCode == http.StatusTooManyRequests: wait := rateLimitBackoff(resp, attempt) - slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait) + slog.WarnContext(ctx, "rate limited by AI API, waiting", "component", "ai", "api", label, "attempt", attempt+1, "wait", wait) lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300)) if waitErr := sleepCtx(ctx, wait); waitErr != nil { return waitErr diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index d52cd55e..59b283a9 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -388,7 +388,7 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) { } lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs) if err != nil { - slog.Warn("failed to load admin user last activity", "error", err) + slog.WarnContext(r.Context(), "failed to load admin user last activity", "component", "api", "error", err) } for i := range resp { applyLastActiveAt(&resp[i], lastActive) @@ -415,7 +415,7 @@ func (h *AdminHandler) HandleGetUser(w http.ResponseWriter, r *http.Request) { resp := toAdminUserResponse(user) lastActive, err := h.loadUserLastActiveAt(r.Context(), []int{user.ID}) if err != nil { - slog.Warn("failed to load admin user last activity", "user_id", user.ID, "error", err) + slog.WarnContext(r.Context(), "failed to load admin user last activity", "component", "api", "user_id", user.ID, "error", err) } applyLastActiveAt(&resp, lastActive) @@ -1018,7 +1018,7 @@ func (h *AdminHandler) publishStatsEvent(ctx context.Context, channel, eventType return } if err := h.EventBus.Publish(ctx, channel, cache.Event{Type: eventType, Payload: payload}); err != nil { - slog.Warn("admin: failed to publish stats invalidation event", + slog.WarnContext(ctx, "admin: failed to publish stats invalidation event", "component", "api", "channel", channel, "type", eventType, "error", err, @@ -1074,7 +1074,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http. writeError(w, scopeErr.StatusCode, code, scopeErr.Message) return } - slog.Error("admin: resolve item refresh scope failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin: resolve item refresh scope failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item refresh scope") return } @@ -1086,7 +1086,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http. Message: "Queued item metadata refresh", }) if err != nil { - slog.Error("admin: create item refresh job failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin: create item refresh job failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue item metadata refresh") return } @@ -1171,13 +1171,13 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R // Try media_items first, then seasons, then episodes. if err := h.DetailSvc.UpdateMediaItemMetadata(r.Context(), contentID, &upd); err != nil { if !errors.Is(err, catalog.ErrItemNotFound) { - slog.Error("admin: update item metadata failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin: update item metadata failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata") return } if err := h.DetailSvc.UpdateSeasonMetadata(r.Context(), contentID, &upd); err != nil { if !errors.Is(err, catalog.ErrSeasonNotFound) { - slog.Error("admin: update season metadata failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin: update season metadata failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata") return } @@ -1186,7 +1186,7 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R writeError(w, http.StatusNotFound, "not_found", "Item not found") return } - slog.Error("admin: update episode metadata failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin: update episode metadata failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata") return } @@ -1203,7 +1203,7 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R detail, err := h.DetailSvc.GetItemDetail(r.Context(), contentID, catalog.AccessFilter{}) if err != nil { - slog.Error("admin: fetch updated detail failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin: fetch updated detail failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Updated but failed to fetch result") return } @@ -1725,7 +1725,7 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) } profileNames, err := listProfileNamesByID(gctx, store) if err != nil { - slog.Warn("admin list devices profile lookup failed", + slog.WarnContext(r.Context(), "admin list devices profile lookup failed", "component", "api", "user_id", user.ID, "error", err, ) @@ -1743,7 +1743,7 @@ func (h *AdminHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) }) } if err := g.Wait(); err != nil { - slog.Error("admin list devices failed", "error", err) + slog.ErrorContext(r.Context(), "admin list devices failed", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices") return } diff --git a/internal/api/handlers/admin_images.go b/internal/api/handlers/admin_images.go index 4d0f101e..4a941f15 100644 --- a/internal/api/handlers/admin_images.go +++ b/internal/api/handlers/admin_images.go @@ -191,7 +191,7 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R writeError(w, http.StatusNotFound, "not_found", "Item not found") return } - slog.Error("admin images: resolve content ID failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin images: resolve content ID failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item") return } @@ -201,7 +201,7 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R lookupContentID := resolved.parentItem.ContentID folderID, err := h.resolveImageFolderID(r.Context(), lookupContentID) if err != nil { - slog.Error("admin images: resolve folder failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin images: resolve folder failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Could not determine library for item") return } @@ -221,7 +221,7 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R r.Context(), providerIDs, resolved.parentItem.Type, language, folderID, ) if err != nil { - slog.Error("admin images: fetch failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin images: fetch failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch images") return } @@ -298,7 +298,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http. writeError(w, http.StatusNotFound, "not_found", "Item not found") return } - slog.Error("admin images: resolve content ID failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin images: resolve content ID failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item") return } @@ -339,7 +339,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http. EpisodeNumber: episodeNumber, }) if err != nil { - slog.Error("admin images: apply failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin images: apply failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply image") return } @@ -356,7 +356,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http. // Persist to the correct table. if err := h.persistImageUpdate(r.Context(), contentID, resolved, &upd); err != nil { - slog.Error("admin images: persist failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin images: persist failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Image cached but failed to update item") return } @@ -366,7 +366,7 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http. parentLocked := mergeLockedField(resolved.parentItem.LockedFields, int(metadata.FieldImages)) parentUpd := catalog.MetadataUpdate{LockedFields: &parentLocked} if lockErr := h.detailSvc.UpdateMediaItemMetadata(r.Context(), resolved.parentItem.ContentID, &parentUpd); lockErr != nil { - slog.Error("admin images: failed to lock FieldImages on parent series", + slog.ErrorContext(r.Context(), "admin images: failed to lock FieldImages on parent series", "component", "api", "content_id", contentID, "parent_id", resolved.parentItem.ContentID, "error", lockErr) } } diff --git a/internal/api/handlers/admin_intro.go b/internal/api/handlers/admin_intro.go index 28496358..766899f3 100644 --- a/internal/api/handlers/admin_intro.go +++ b/internal/api/handlers/admin_intro.go @@ -92,7 +92,7 @@ func (h *AdminIntroHandler) handleEpisodeMarkers(w http.ResponseWriter, r *http. writeError(w, http.StatusBadRequest, "bad_request", "Item must be an episode") return } - h.logger.Error("admin intro: resolve episode failed", "episode_id", episodeID, "error", err) + h.logger.ErrorContext(r.Context(), "admin intro: resolve episode failed", "episode_id", episodeID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve episode") return } @@ -110,7 +110,7 @@ func (h *AdminIntroHandler) handleEpisodeMarkers(w http.ResponseWriter, r *http. } raw, err := h.Settings.Get(r.Context(), markers.SettingMode) if err != nil { - h.logger.Error("admin markers: load mode failed", "episode_id", episodeID, "error", err) + h.logger.ErrorContext(r.Context(), "admin markers: load mode failed", "episode_id", episodeID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker settings") return } @@ -135,17 +135,17 @@ func (h *AdminIntroHandler) handleEpisodeMarkers(w http.ResponseWriter, r *http. go func() { defer h.inFlight.Delete(episodeID) start := time.Now() - h.logger.Info("admin markers: episode refresh started", "episode_id", episodeID, "action", action) + h.logger.InfoContext(r.Context(), "admin markers: episode refresh started", "episode_id", episodeID, "action", action) summary, err := h.analyzer.AnalyzeEpisode(h.baseContext, episodeID) if err != nil { - h.logger.Error("admin markers: episode refresh failed", + h.logger.ErrorContext(r.Context(), "admin markers: episode refresh failed", "episode_id", episodeID, "action", action, "duration", time.Since(start), "error", err) return } - h.logger.Info("admin markers: episode refresh finished", + h.logger.InfoContext(r.Context(), "admin markers: episode refresh finished", "episode_id", episodeID, "action", action, "duration", time.Since(start), @@ -168,7 +168,7 @@ func (h *AdminIntroHandler) notifyEpisodeMarkerUpdates(ctx context.Context, epis } files, err := h.FileResolver.GetByEpisodeID(ctx, episodeID) if err != nil { - h.logger.Warn("admin markers: reload episode files for marker update failed", + h.logger.WarnContext(ctx, "admin markers: reload episode files for marker update failed", "episode_id", episodeID, "action", action, "error", err) @@ -179,7 +179,7 @@ func (h *AdminIntroHandler) notifyEpisodeMarkerUpdates(ctx context.Context, epis continue } h.MarkerUpdateNotifier.MarkersUpdated(ctx, file) - h.logger.Info("admin markers: emitted marker update", + h.logger.InfoContext(ctx, "admin markers: emitted marker update", "episode_id", episodeID, "action", action, "file_id", file.ID) diff --git a/internal/api/handlers/admin_logs.go b/internal/api/handlers/admin_logs.go index b3519ecb..50af411f 100644 --- a/internal/api/handlers/admin_logs.go +++ b/internal/api/handlers/admin_logs.go @@ -245,7 +245,7 @@ func (h *AdminLogsHandler) HandleLogStreamWebSocket(w http.ResponseWriter, r *ht } result, err := h.opsRepo.List(context.Background(), appOpts) if err != nil { - slog.Error("admin log stream operational snapshot failed", "error", err) + slog.ErrorContext(r.Context(), "admin log stream operational snapshot failed", "component", "api", "error", err) h.writeStreamError(conn, stream, "internal_error", "Failed to query operational logs") return } @@ -285,7 +285,7 @@ func (h *AdminLogsHandler) HandleLogStreamWebSocket(w http.ResponseWriter, r *ht } result, err := h.auditRepo.List(context.Background(), auditOpts) if err != nil { - slog.Error("admin log stream audit snapshot failed", "error", err) + slog.ErrorContext(r.Context(), "admin log stream audit snapshot failed", "component", "api", "error", err) h.writeStreamError(conn, stream, "internal_error", "Failed to query audit logs") return } diff --git a/internal/api/handlers/admin_marker_providers.go b/internal/api/handlers/admin_marker_providers.go index e2ac60a4..a952b07f 100644 --- a/internal/api/handlers/admin_marker_providers.go +++ b/internal/api/handlers/admin_marker_providers.go @@ -162,7 +162,7 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter existing.ContributeMinConfidence = v } if err := h.Config.Update(r.Context(), existing); err != nil { - h.logger.Error("admin markers: update provider config failed", "provider", provider, "error", err) + h.logger.ErrorContext(r.Context(), "admin markers: update provider config failed", "provider", provider, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update provider") return } diff --git a/internal/api/handlers/admin_match.go b/internal/api/handlers/admin_match.go index 3d1b6acd..f2e127c9 100644 --- a/internal/api/handlers/admin_match.go +++ b/internal/api/handlers/admin_match.go @@ -100,7 +100,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite // Load the existing item to infer content type. item, err := h.items.GetByID(r.Context(), contentID) if err != nil { - slog.Warn("admin match: item not found", "content_id", contentID, "error", err) + slog.WarnContext(r.Context(), "admin match: item not found", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusNotFound, "not_found", "Item not found") return } @@ -111,7 +111,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for items in multiple libraries") return } - slog.Warn("admin match: resolve folder failed", "content_id", contentID, "error", err) + slog.WarnContext(r.Context(), "admin match: resolve folder failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") return } @@ -137,7 +137,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite candidates, err := h.metadata.SearchAndNormalize(r.Context(), query, folderID) if err != nil { - slog.Error("admin match: search failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin match: search failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Metadata search failed") return } @@ -173,7 +173,7 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http. // Verify the item exists. _, err := h.items.GetByID(r.Context(), contentID) if err != nil { - slog.Warn("admin match: item not found for apply", "content_id", contentID, "error", err) + slog.WarnContext(r.Context(), "admin match: item not found for apply", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusNotFound, "not_found", "Item not found") return } @@ -184,7 +184,7 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http. writeError(w, http.StatusBadRequest, "bad_request", "library_id is required for items in multiple libraries") return } - slog.Warn("admin match: resolve folder failed for apply", "content_id", contentID, "error", err) + slog.WarnContext(r.Context(), "admin match: resolve folder failed for apply", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id") return } @@ -200,7 +200,7 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http. Mode: metadata.ModeIdentify, }) if err != nil { - slog.Error("admin match: apply failed", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin match: apply failed", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply match") return } diff --git a/internal/api/handlers/admin_split.go b/internal/api/handlers/admin_split.go index 54696acc..5a6afc51 100644 --- a/internal/api/handlers/admin_split.go +++ b/internal/api/handlers/admin_split.go @@ -140,7 +140,7 @@ func (h *AdminSplitHandler) HandleListItemFiles(w http.ResponseWriter, r *http.R } files, err := h.loadItemFiles(r.Context(), contentID) if err != nil { - slog.Error("admin split: listing item files", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "admin split: listing item files", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") return } @@ -196,7 +196,7 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque files, err := h.loadItemFiles(ctx, sourceID) if err != nil { - slog.Error("admin split: loading item files", "content_id", sourceID, "error", err) + slog.ErrorContext(ctx, "admin split: loading item files", "component", "api", "content_id", sourceID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") return } @@ -234,7 +234,7 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque // Everything transactional happens here; a dry run rolls back at the end. tx, err := h.pool.Begin(ctx) if err != nil { - slog.Error("admin split: begin transaction", "error", err) + slog.ErrorContext(ctx, "admin split: begin transaction", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start split") return } @@ -242,13 +242,13 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque if target.created { if err := insertSkeletonItem(ctx, tx, target, sourceItem); err != nil { - slog.Error("admin split: creating target item", "target", target.contentID, "error", err) + slog.ErrorContext(ctx, "admin split: creating target item", "component", "api", "target", target.contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create target item") return } } if err := moveFilesToItem(ctx, tx, target.contentID, moved); err != nil { - slog.Error("admin split: moving files", "target", target.contentID, "error", err) + slog.ErrorContext(ctx, "admin split: moving files", "component", "api", "target", target.contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to move files") return } @@ -258,7 +258,7 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque if persistOverride && target.hasForcedIdentity() { rootOverrides, fileOverrides, err = h.persistOverrides(ctx, tx, moved, target, middleware.GetUserID(ctx)) if err != nil { - slog.Error("admin split: persisting overrides", "error", err) + slog.ErrorContext(ctx, "admin split: persisting overrides", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to persist identity overrides") return } @@ -272,18 +272,18 @@ func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Reque EpisodePairs: episodePairs, }) if err != nil { - slog.Error("admin split: reattributing user state", "error", err) + slog.ErrorContext(ctx, "admin split: reattributing user state", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reattribute user state") return } if !req.DryRun { if err := tx.Commit(ctx); err != nil { - slog.Error("admin split: commit", "error", err) + slog.ErrorContext(ctx, "admin split: commit", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to commit split") return } - slog.Info("admin split: item split", + slog.InfoContext(ctx, "admin split: item split", "component", "api", "actor_user_id", middleware.GetUserID(ctx), "source_content_id", sourceID, "target_content_id", target.contentID, @@ -337,18 +337,18 @@ func (h *AdminSplitHandler) HandleMergeItem(w http.ResponseWriter, r *http.Reque writeError(w, http.StatusNotFound, "not_found", "Item not found") return } - slog.Warn("admin merge: failed", "source", sourceID, "target", req.Into, "error", err) + slog.WarnContext(r.Context(), "admin merge: failed", "component", "api", "source", sourceID, "target", req.Into, "error", err) writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - slog.Info("admin merge: item merged", + slog.InfoContext(r.Context(), "admin merge: item merged", "component", "api", "actor_user_id", middleware.GetUserID(r.Context()), "source_content_id", sourceID, "target_content_id", req.Into, ) if h.refresher != nil { if err := h.refresher.RefreshItem(context.WithoutCancel(r.Context()), req.Into); err != nil { - slog.Warn("admin merge: target refresh failed", "content_id", req.Into, "error", err) + slog.WarnContext(r.Context(), "admin merge: target refresh failed", "component", "api", "content_id", req.Into, "error", err) } } writeJSON(w, http.StatusOK, map[string]any{"merged_into": req.Into}) diff --git a/internal/api/handlers/admin_stats.go b/internal/api/handlers/admin_stats.go index 0042dc64..535dc9ba 100644 --- a/internal/api/handlers/admin_stats.go +++ b/internal/api/handlers/admin_stats.go @@ -219,7 +219,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error activity, err := queryWatchProviderActivity(ctx, pool) if err != nil { - slog.Warn("failed to query watch provider admin stats", "error", err) + slog.WarnContext(ctx, "failed to query watch provider admin stats", "component", "api", "error", err) activity = WatchProviderActivity{} } diff --git a/internal/api/handlers/autoscan.go b/internal/api/handlers/autoscan.go index fb91a0cb..ca005667 100644 --- a/internal/api/handlers/autoscan.go +++ b/internal/api/handlers/autoscan.go @@ -145,7 +145,7 @@ func (h *AutoscanHandler) HandleUpdateSettings(w http.ResponseWriter, r *http.Re if terr := h.triggers.UpdateTriggers(autoscanPollTaskKey, []taskmanager.TriggerConfig{ {Type: taskmanager.TriggerTypeInterval, IntervalMs: intervalMs}, }); terr != nil { - slog.WarnContext(r.Context(), "autoscan: reschedule poll task failed", "err", terr) + slog.WarnContext(r.Context(), "autoscan: reschedule poll task failed", "component", "api", "err", terr) } } writeJSON(w, http.StatusOK, settingsResponse(updated)) @@ -687,7 +687,7 @@ func (h *AutoscanHandler) HandleTrigger(w http.ResponseWriter, r *http.Request) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if err := h.svc.PollOnce(ctx); err != nil { - slog.WarnContext(ctx, "autoscan: manual poll failed", "err", err) + slog.WarnContext(ctx, "autoscan: manual poll failed", "component", "api", "err", err) } }() writeJSON(w, http.StatusAccepted, struct { diff --git a/internal/api/handlers/calendar.go b/internal/api/handlers/calendar.go index 6ead393d..6dab0f44 100644 --- a/internal/api/handlers/calendar.go +++ b/internal/api/handlers/calendar.go @@ -241,7 +241,7 @@ func (h *CalendarHandler) resolveWatched(ctx context.Context, af catalog.AccessF } watched, err := h.personal.ListWatchedItemIDs(ctx, af.UserID, af.ProfileID, ids) if err != nil { - slog.WarnContext(ctx, "calendar watched overlay failed", "error", err) + slog.WarnContext(ctx, "calendar watched overlay failed", "component", "api", "error", err) return map[string]bool{} } return watched diff --git a/internal/api/handlers/catalog.go b/internal/api/handlers/catalog.go index 9ffbc2c6..b10c9c45 100644 --- a/internal/api/handlers/catalog.go +++ b/internal/api/handlers/catalog.go @@ -114,7 +114,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request writeError(w, http.StatusNotFound, "not_found", "Catalog source not found") return } - slog.Error("catalog: resolve grouped by work failed", "err_msg", err.Error()) + slog.ErrorContext(r.Context(), "catalog: resolve grouped by work failed", "component", "api", "err_msg", err.Error()) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog") return } @@ -140,7 +140,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request writeError(w, http.StatusNotFound, "not_found", "Catalog source not found") return } - slog.Error("catalog: resolve failed", "err_msg", err.Error()) + slog.ErrorContext(r.Context(), "catalog: resolve failed", "component", "api", "err_msg", err.Error()) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog") return } @@ -731,7 +731,7 @@ func (h *CatalogHandler) HandleLegacySearch(w http.ResponseWriter, r *http.Reque items, total, err := h.itemsH.itemRepo.Search(r.Context(), query, parseSearchTypes(r.URL.Query()["type"]), limit, offset, h.itemsH.accessFilter(r)) if err != nil { - slog.Error("search failed", "query", query, "error", err) + slog.ErrorContext(r.Context(), "search failed", "component", "api", "query", query, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Search failed") return } diff --git a/internal/api/handlers/downloads.go b/internal/api/handlers/downloads.go index de733490..e3279bc7 100644 --- a/internal/api/handlers/downloads.go +++ b/internal/api/handlers/downloads.go @@ -173,7 +173,7 @@ func (h *DownloadHandler) HandleCapability(w http.ResponseWriter, r *http.Reques capInfo, err := h.svc.Capability(r.Context(), userID) if err != nil { - slog.Error("failed to load download capability", "user_id", userID, "error", err) + slog.ErrorContext(r.Context(), "failed to load download capability", "component", "api", "user_id", userID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load download capability") return } @@ -297,7 +297,7 @@ func (h *DownloadHandler) HandleListDownloads(w http.ResponseWriter, r *http.Req writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads") return } - slog.Error("failed to list downloads", "user_id", userID, "error", err) + slog.ErrorContext(r.Context(), "failed to list downloads", "component", "api", "user_id", userID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list downloads") return } @@ -336,7 +336,7 @@ func (h *DownloadHandler) HandleDeleteDownload(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads") return } - slog.Error("failed to delete download", "download_id", id, "error", err) + slog.ErrorContext(r.Context(), "failed to delete download", "component", "api", "download_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete download") return } @@ -371,7 +371,7 @@ func (h *DownloadHandler) HandlePatchDownload(w http.ResponseWriter, r *http.Req case errors.Is(err, downloads.ErrProfileRequired): writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads") default: - slog.Error("failed to patch download", "download_id", id, "error", err) + slog.ErrorContext(r.Context(), "failed to patch download", "component", "api", "download_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update download") } return @@ -415,7 +415,7 @@ func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download") return } - slog.Error("failed to serve download file", "download_id", id, "error", err) + slog.ErrorContext(r.Context(), "failed to serve download file", "component", "api", "download_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve download") return } diff --git a/internal/api/handlers/events_publish.go b/internal/api/handlers/events_publish.go index 0e4f9df7..76c332d3 100644 --- a/internal/api/handlers/events_publish.go +++ b/internal/api/handlers/events_publish.go @@ -15,7 +15,7 @@ func publishEventJob(ctx context.Context, hub *evt.Hub, eventName string, job *m if err := hub.PublishJSON(ctx, evt.ChannelJobs, eventName, job, evt.PublishOptions{ AdminOnly: true, }); err != nil { - slog.Warn("events: failed to publish job event", "job_id", job.ID, "event", eventName, "error", err) + slog.WarnContext(ctx, "events: failed to publish job event", "component", "api", "job_id", job.ID, "event", eventName, "error", err) } } @@ -28,6 +28,6 @@ func publishEventMetadataUpdate(ctx context.Context, hub *evt.Hub, libraryID int "content_id": contentID, "change": "metadata_updated", }, evt.PublishOptions{}); err != nil { - slog.Warn("events: failed to publish metadata update", "library_id", libraryID, "content_id", contentID, "error", err) + slog.WarnContext(ctx, "events: failed to publish metadata update", "component", "api", "library_id", libraryID, "content_id", contentID, "error", err) } } diff --git a/internal/api/handlers/events_ws.go b/internal/api/handlers/events_ws.go index 77927ed9..c771abf8 100644 --- a/internal/api/handlers/events_ws.go +++ b/internal/api/handlers/events_ws.go @@ -101,7 +101,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) // the client retries it when its notifications subscription is // rejected — whereas a hard 403 would take down every realtime // channel over a notifications-only concern. - slog.Warn("events: websocket ticket rejected; connection unbound", + slog.WarnContext(r.Context(), "events: websocket ticket rejected; connection unbound", "component", "api", "user_id", claims.UserID) } } @@ -463,8 +463,8 @@ func (h *EventsHandler) writeSnapshotFrame( ) error { data, err := h.snapshotForChannel(r, claims, boundProfileID, channel) if err != nil { - slog.Error( - "events: failed to build initial snapshot", + slog.ErrorContext(r.Context(), + "events: failed to build initial snapshot", "component", "api", "channel", channel, "user_id", @@ -498,7 +498,7 @@ func (h *EventsHandler) writeEventFrame( // writeSnapshotFrame): durable state covers the gap on the next // event or reconnect, while closing the socket tears down every // channel the client subscribed to. - slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err) + slog.ErrorContext(r.Context(), "events: failed to build event payload", "component", "api", "channel", env.Channel, "event", env.Event, "error", err) return nil } data = snapshot diff --git a/internal/api/handlers/history_import.go b/internal/api/handlers/history_import.go index 8f91a904..635af10e 100644 --- a/internal/api/handlers/history_import.go +++ b/internal/api/handlers/history_import.go @@ -50,7 +50,7 @@ func (h *HistoryImportHandler) HandleLoginConnect(w http.ResponseWriter, r *http session, err := h.service.LoginConnect(r.Context(), userID, req) if err != nil { - slog.Error("history import emby connect login failed", "user_id", userID, "error", err) + slog.ErrorContext(r.Context(), "history import emby connect login failed", "component", "api", "user_id", userID, "error", err) h.writeHistoryImportError(w, err) return } @@ -120,7 +120,7 @@ func (h *HistoryImportHandler) HandleCreatePlexPin(w http.ResponseWriter, r *htt } pin, err := h.service.CreatePlexPin(r.Context(), userID) if err != nil { - slog.Error("history import plex pin creation failed", "user_id", userID, "error", err) + slog.ErrorContext(r.Context(), "history import plex pin creation failed", "component", "api", "user_id", userID, "error", err) h.writeHistoryImportError(w, err) return } diff --git a/internal/api/handlers/history_import_admin.go b/internal/api/handlers/history_import_admin.go index 7ff7e699..c6005899 100644 --- a/internal/api/handlers/history_import_admin.go +++ b/internal/api/handlers/history_import_admin.go @@ -64,7 +64,7 @@ func (h *HistoryImportHandler) HandleAdminDiscoverUsers(w http.ResponseWriter, r } users, err := h.service.DiscoverExternalUsers(r.Context(), id) if err != nil { - slog.Error("history import: discover external users failed", "source_id", id, "error", err) + slog.ErrorContext(r.Context(), "history import: discover external users failed", "component", "api", "source_id", id, "error", err) h.writeDiscoverError(w, err) return } @@ -281,7 +281,7 @@ func (h *HistoryImportHandler) HandleAdminPlexLogin(w http.ResponseWriter, r *ht } token, err := h.service.AuthenticatePlex(r.Context(), req.Username, req.Password) if err != nil { - slog.Error("history import: plex login failed", "error", err) + slog.ErrorContext(r.Context(), "history import: plex login failed", "component", "api", "error", err) h.writeDiscoverError(w, err) return } diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index 71df8fd3..3a5cb5a4 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -205,7 +205,7 @@ func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetTy return } if err := h.metadataRefreshRequester.RequestStaleMetadataRefresh(ctx, targetType, contentID); err != nil { - slog.Warn("catalog: failed to request stale metadata refresh", + slog.WarnContext(ctx, "catalog: failed to request stale metadata refresh", "component", "api", "target_type", targetType, "content_id", contentID, "error", err) @@ -457,7 +457,7 @@ func (h *ItemsHandler) dispatchLocalWatchEvent( ProfileID: profileID, Plays: plays, }); err != nil { - slog.Warn("failed to queue local watch provider event", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "failed to queue local watch provider event", "component", "api", "kind", kind, "user_id", userID, "profile_id", profileID, "error", err) } } @@ -1830,7 +1830,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter { if userID != 0 { user, userErr := h.UserRepo.GetByID(r.Context(), userID) if userErr != nil { - slog.Error("looking up user for library access", "error", userErr) + slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr) } else { if user.LibraryIDs != nil { libraryIDs = user.LibraryIDs @@ -2004,7 +2004,7 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool { } user, err := h.UserRepo.GetByID(r.Context(), claims.UserID) if err != nil { - slog.WarnContext(r.Context(), "checking file path visibility permissions", "user_id", claims.UserID, "error", err) + slog.WarnContext(r.Context(), "checking file path visibility permissions", "component", "api", "user_id", claims.UserID, "error", err) return false } return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index e6e8d985..d2dfe344 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -424,7 +424,7 @@ func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http. if h.userRepo != nil { user, userErr := h.userRepo.GetByID(r.Context(), userID) if userErr != nil { - slog.Error("looking up user for library access", "error", userErr) + slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up user") return } @@ -440,7 +440,7 @@ func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http. } if err != nil { - slog.Error("listing user libraries", "error", err) + slog.ErrorContext(r.Context(), "listing user libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") return } @@ -472,7 +472,7 @@ func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http. func (h *LibraryHandler) HandleListLibraries(w http.ResponseWriter, r *http.Request) { folders, err := h.folderRepo.List(r.Context()) if err != nil { - slog.Error("listing libraries", "error", err) + slog.ErrorContext(r.Context(), "listing libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") return } @@ -498,7 +498,7 @@ func (h *LibraryHandler) HandleReorderLibraries(w http.ResponseWriter, r *http.R return } if err := h.folderRepo.Reorder(r.Context(), req.Entries); err != nil { - slog.Error("reordering libraries", "error", err) + slog.ErrorContext(r.Context(), "reordering libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder libraries") return } @@ -514,7 +514,7 @@ func (h *LibraryHandler) HandleListSkippedRoots(w http.ResponseWriter, r *http.R folders, err := h.folderRepo.List(r.Context()) if err != nil { - slog.Error("listing libraries for skipped roots", "error", err) + slog.ErrorContext(r.Context(), "listing libraries for skipped roots", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") return } @@ -526,7 +526,7 @@ func (h *LibraryHandler) HandleListSkippedRoots(w http.ResponseWriter, r *http.R roots, err := h.SkippedRootRepo.ListAll(r.Context()) if err != nil { - slog.Error("listing skipped roots", "error", err) + slog.ErrorContext(r.Context(), "listing skipped roots", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list skipped roots") return } @@ -583,7 +583,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusConflict, "conflict", "A library with this path already exists") return } - slog.Error("creating library", "error", err) + slog.ErrorContext(r.Context(), "creating library", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create library") return } @@ -591,15 +591,15 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ // Seed default sections for the new library. if h.SectionRepo != nil { if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil { - slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr) + slog.WarnContext(r.Context(), "seed default sections for new library", "component", "api", "library_id", folder.ID, "error", seedErr) } if sections.IsAudiobookLibraryType(folder.Type) { if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil { - slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr) + slog.WarnContext(r.Context(), "ensure home continue listening section", "component", "api", "library_id", folder.ID, "error", seedErr) } } if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil { - slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr) + slog.WarnContext(r.Context(), "seed generated home sections for new library", "component", "api", "library_id", folder.ID, "error", seedErr) } } @@ -608,7 +608,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ entries := h.seedDefaultChain(r.Context(), req.Type) if len(entries) > 0 { if seedErr := h.ChainRepo.SetChain(r.Context(), folder.ID, entries); seedErr != nil { - slog.Warn("seed default chain failed", "folder_id", folder.ID, "error", seedErr) + slog.WarnContext(r.Context(), "seed default chain failed", "component", "api", "folder_id", folder.ID, "error", seedErr) } } } @@ -616,7 +616,7 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ // Kick off an initial scan so content appears immediately. if h.ScanQueue != nil { if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_created"); err != nil { - slog.Warn("queue initial library scan failed", "library_id", folder.ID, "error", err) + slog.WarnContext(r.Context(), "queue initial library scan failed", "component", "api", "library_id", folder.ID, "error", err) } } else { initialScanID := ulid.Make().String() @@ -660,7 +660,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusNotFound, "not_found", "Library not found") return } - slog.Error("fetching library for update", "error", err) + slog.ErrorContext(r.Context(), "fetching library for update", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } @@ -685,7 +685,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusConflict, "conflict", "A library with this path already exists") return } - slog.Error("updating library", "error", err) + slog.ErrorContext(r.Context(), "updating library", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update library") return } @@ -693,14 +693,14 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ // Fetch the updated folder to return it. folder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { - slog.Error("fetching updated library", "error", err) + slog.ErrorContext(r.Context(), "fetching updated library", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated library") return } if h.SectionRepo != nil && oldFolder.Name != folder.Name { if syncErr := h.SectionRepo.SyncGeneratedHomeLibraryRecentTitles(r.Context(), id, oldFolder.Name, folder.Name); syncErr != nil { - slog.Warn("sync generated home section titles", "library_id", id, "error", syncErr) + slog.WarnContext(r.Context(), "sync generated home section titles", "component", "api", "library_id", id, "error", syncErr) } } @@ -717,7 +717,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ if jobErr != nil { var conflict *adminjob.ActiveJobConflictError if !errors.As(jobErr, &conflict) { - slog.Warn("queue language-change metadata refresh failed", "library_id", folder.ID, "error", jobErr) + slog.WarnContext(r.Context(), "queue language-change metadata refresh failed", "component", "api", "library_id", folder.ID, "error", jobErr) } } else { publishEventJob(r.Context(), h.EventsHub, "job.created", job) @@ -728,7 +728,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ if req.Paths != nil && !slices.Equal(oldFolder.Paths, *req.Paths) { if h.ScanQueue != nil { if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_paths_changed"); err != nil { - slog.Warn("queue library path-change scan failed", "library_id", folder.ID, "error", err) + slog.WarnContext(r.Context(), "queue library path-change scan failed", "component", "api", "library_id", folder.ID, "error", err) } } else { updateScanID := ulid.Make().String() @@ -763,7 +763,7 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ writeError(w, http.StatusNotFound, "not_found", "Library not found") return } - slog.Error("fetching library before delete", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "fetching library before delete", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library") return } @@ -772,7 +772,7 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ if wasEnabled { disabled := false if err := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &disabled}); err != nil { - slog.Error("disabling library before delete", "library_id", folder.ID, "error", err) + slog.ErrorContext(r.Context(), "disabling library before delete", "component", "api", "library_id", folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare library deletion") return } @@ -792,7 +792,7 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ if wasEnabled { enabled := true if revertErr := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &enabled}); revertErr != nil { - slog.Error("re-enabling library after failed delete queue", + slog.ErrorContext(r.Context(), "re-enabling library after failed delete queue", "component", "api", "library_id", folder.ID, "queue_error", err, "revert_error", revertErr, @@ -805,21 +805,21 @@ func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Requ writeAdminJobConflict(w, "A library deletion is already queued or running", conflict.Job, jobsHandler, r) return } - slog.Error("queuing library delete job", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "queuing library delete job", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library delete") return } if h.ingester != nil { cancelled := h.ingester.CancelLibrary(folder.ID) - slog.Info("library delete: cancelled running scans", "library_id", folder.ID, "cancelled", cancelled) + slog.InfoContext(r.Context(), "library delete: cancelled running scans", "component", "api", "library_id", folder.ID, "cancelled", cancelled) } if h.ScanQueue != nil { queuedCancelled, err := h.ScanQueue.CancelAcceptedByLibrary(r.Context(), folder.ID) if err != nil { - slog.Warn("library delete: failed to cancel queued scans", "library_id", folder.ID, "error", err) + slog.WarnContext(r.Context(), "library delete: failed to cancel queued scans", "component", "api", "library_id", folder.ID, "error", err) } else if queuedCancelled > 0 { - slog.Info("library delete: cancelled queued scans", "library_id", folder.ID, "cancelled", queuedCancelled) + slog.InfoContext(r.Context(), "library delete: cancelled queued scans", "component", "api", "library_id", folder.ID, "cancelled", queuedCancelled) } } publishEventJob(r.Context(), h.EventsHub, "job.created", job) @@ -842,7 +842,7 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http. writeError(w, http.StatusNotFound, "not_found", "Library not found") return } - slog.Error("fetching library for mount check", "error", err) + slog.ErrorContext(r.Context(), "fetching library for mount check", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } @@ -854,7 +854,7 @@ func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http. writeError(w, http.StatusNotFound, "not_found", "Library not found") return } - slog.Error("clearing empty-root warning after successful mount check", "library_id", folder.ID, "error", err) + slog.ErrorContext(r.Context(), "clearing empty-root warning after successful mount check", "component", "api", "library_id", folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear library warning") return } @@ -882,14 +882,14 @@ func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) return } - slog.Error("resolving scan target", "error", err) + slog.ErrorContext(r.Context(), "resolving scan target", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve scan target") return } if h.ScanQueue != nil { if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil { - slog.Error("queueing library scan", "library_id", target.Folder.ID, "error", err) + slog.ErrorContext(r.Context(), "queueing library scan", "component", "api", "library_id", target.Folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan") return } @@ -937,7 +937,7 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request if h.ScanQueue != nil { queuedCancelled, err := h.ScanQueue.CancelByLibrary(r.Context(), req.LibraryID) if err != nil { - slog.Error("cancel library scans", "library_id", req.LibraryID, "error", err) + slog.ErrorContext(r.Context(), "cancel library scans", "component", "api", "library_id", req.LibraryID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel scans") return } @@ -949,7 +949,7 @@ func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request for _, run := range h.cancelActiveScans(req.LibraryID) { h.publishScanEvent(r.Context(), "scan.cancelled", run) } - slog.Info("scan: cancelled running scans", + slog.InfoContext(r.Context(), "scan: cancelled running scans", "component", "api", "library_id", req.LibraryID, "cancelled", cancelled, ) @@ -1422,7 +1422,7 @@ func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r folders, err := h.folderRepo.List(r.Context()) if err != nil { - slog.Error("metadata queue: failed to list libraries", "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to list libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queues") return } @@ -1434,7 +1434,7 @@ func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r } status, err := h.metadataMatchQueueStatus(r.Context(), folder.ID) if err != nil { - slog.Error("metadata queue: failed to load queue status", "library_id", folder.ID, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to load queue status", "component", "api", "library_id", folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") return } @@ -1473,7 +1473,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h status, err := h.metadataMatchQueueStatus(r.Context(), id) if err != nil { - slog.Error("metadata queue: failed to load queue status", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to load queue status", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") return } @@ -1487,7 +1487,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h if h.MovieMatchQueueRepo != nil { movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0) if err != nil { - slog.Error("metadata queue: failed to list movie queue", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to list movie queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue") return } @@ -1508,7 +1508,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h if h.SeriesMatchQueueRepo != nil { series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, 0) if err != nil { - slog.Error("metadata queue: failed to list series queue", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to list series queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue") return } @@ -1528,7 +1528,7 @@ func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *h if h.RawMatchBacklogRepo != nil { rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, 0) if err != nil { - slog.Error("metadata queue: failed to list raw backlog", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to list raw backlog", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher backlog") return } @@ -1575,14 +1575,14 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r if h.SeriesMatchQueueRepo != nil { if err := h.SeriesMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil { - slog.Error("metadata queue: failed to retry series queue", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to retry series queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } } if h.MovieMatchQueueRepo != nil { if err := h.MovieMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil { - slog.Error("metadata queue: failed to retry movie queue", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to retry movie queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } @@ -1591,7 +1591,7 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r if h.RawMatchBacklogRepo != nil { rawFileRetried, err = h.RawMatchBacklogRepo.RetryUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode()) if err != nil { - slog.Error("metadata queue: failed to retry raw backlog", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to retry raw backlog", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } @@ -1599,7 +1599,7 @@ func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r status, err := h.metadataMatchQueueStatus(r.Context(), id) if err != nil { - slog.Error("metadata queue: failed to load retried queue status", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to load retried queue status", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") return } @@ -1635,7 +1635,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r if h.SeriesMatchQueueRepo != nil { seriesCancelled, err = h.SeriesMatchQueueRepo.DeleteByFolder(r.Context(), id) if err != nil { - slog.Error("metadata queue: failed to cancel series queue", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to cancel series queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") return } @@ -1644,7 +1644,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r if h.MovieMatchQueueRepo != nil { movieCancelled, err = h.MovieMatchQueueRepo.DeleteByFolder(r.Context(), id) if err != nil { - slog.Error("metadata queue: failed to cancel movie queue", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to cancel movie queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") return } @@ -1653,7 +1653,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r if h.RawMatchBacklogRepo != nil { rawFileCancelled, err = h.RawMatchBacklogRepo.SuppressUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode()) if err != nil { - slog.Error("metadata queue: failed to suppress raw backlog", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to suppress raw backlog", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") return } @@ -1661,7 +1661,7 @@ func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r status, err := h.metadataMatchQueueStatus(r.Context(), id) if err != nil { - slog.Error("metadata queue: failed to load cancelled queue status", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "metadata queue: failed to load cancelled queue status", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") return } @@ -1773,7 +1773,7 @@ func (h *LibraryHandler) HandleRefreshLibraryMetadata(w http.ResponseWriter, r * writeAdminJobConflict(w, "A metadata refresh is already queued or running for this library", conflict.Job, jobsHandler, r) return } - slog.Error("library: queue library refresh failed", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "library: queue library refresh failed", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library metadata refresh") return } @@ -1870,13 +1870,13 @@ func (h *LibraryHandler) HandleUploadPoster(w http.ResponseWriter, r *http.Reque } if err := h.S3Meta.PutObject(r.Context(), h.S3Meta.Bucket(), s3Key, data); err != nil { - slog.Error("uploading library poster", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "uploading library poster", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to upload poster") return } if err := h.folderRepo.SetPosterPath(r.Context(), id, s3Key); err != nil { - slog.Error("saving library poster path", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "saving library poster path", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save poster") return } @@ -1911,7 +1911,7 @@ func (h *LibraryHandler) HandleDeletePoster(w http.ResponseWriter, r *http.Reque if folder.PosterPath != "" { _ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath) if err := h.folderRepo.ClearPosterPath(r.Context(), id); err != nil { - slog.Error("clearing library poster path", "library_id", id, "error", err) + slog.ErrorContext(r.Context(), "clearing library poster path", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear poster") return } @@ -1978,14 +1978,14 @@ func (h *LibraryHandler) HandleGetLibraryProviders(w http.ResponseWriter, r *htt writeError(w, http.StatusNotFound, "not_found", "Library not found") return } - slog.Error("fetching library for provider chain", "error", err) + slog.ErrorContext(r.Context(), "fetching library for provider chain", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } entries, err := h.ChainRepo.GetAllChainEntries(r.Context(), id) if err != nil { - slog.Error("getting provider chain", "error", err) + slog.ErrorContext(r.Context(), "getting provider chain", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get provider chain") return } @@ -2063,7 +2063,7 @@ func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *htt writeError(w, http.StatusNotFound, "not_found", "Library not found") return } - slog.Error("fetching library for provider chain update", "error", err) + slog.ErrorContext(r.Context(), "fetching library for provider chain update", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } @@ -2089,7 +2089,7 @@ func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *htt } if err := h.ChainRepo.SetChain(r.Context(), id, entries); err != nil { - slog.Error("setting provider chain", "error", err) + slog.ErrorContext(r.Context(), "setting provider chain", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set provider chain") return } @@ -2109,7 +2109,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin caps, err := metadata.ListEnabledMetadataCapabilities(ctx, h.ChainRepo.Pool()) if err != nil { - slog.Warn("seed chain: failed to list metadata capabilities", "error", err) + slog.WarnContext(ctx, "seed chain: failed to list metadata capabilities", "component", "api", "error", err) return nil } @@ -2218,7 +2218,7 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque staleIDs, err := h.StaleIDRepo.ListAll(r.Context()) if err != nil { - slog.Error("listing stale media IDs", "error", err) + slog.ErrorContext(r.Context(), "listing stale media IDs", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list stale IDs") return } @@ -2262,7 +2262,7 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque WHERE mi.content_id = ANY($1) `, contentIDs) if err != nil { - slog.Error("loading items for stale IDs", "error", err) + slog.ErrorContext(r.Context(), "loading items for stale IDs", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item data") return } @@ -2272,7 +2272,7 @@ func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Reque var cid, title, ctype, libName string var year, libID int if err := rows.Scan(&cid, &title, &year, &ctype, &libID, &libName); err != nil { - slog.Error("scanning item for stale IDs", "error", err) + slog.ErrorContext(r.Context(), "scanning item for stale IDs", "component", "api", "error", err) continue } items[cid] = itemInfo{Title: title, Year: year, ContentType: ctype, LibraryID: libID, LibraryName: libName} @@ -2316,7 +2316,7 @@ func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Req WHERE content_id = $1 `, contentID) if err != nil { - slog.Error("clearing stale IDs from media item", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "clearing stale IDs from media item", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear IDs") return } @@ -2324,7 +2324,7 @@ func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Req // Remove stale_media_ids records. if h.StaleIDRepo != nil { if err := h.StaleIDRepo.DeleteByContentID(r.Context(), contentID); err != nil { - slog.Error("deleting stale media ID records", "content_id", contentID, "error", err) + slog.ErrorContext(r.Context(), "deleting stale media ID records", "component", "api", "content_id", contentID, "error", err) } } @@ -2332,7 +2332,7 @@ func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Req if h.refresher != nil { go func() { if err := h.refresher.RefreshItem(h.appCtx, contentID); err != nil { - slog.Warn("metadata: rematch refresh failed", "content_id", contentID, "error", err) + slog.WarnContext(r.Context(), "metadata: rematch refresh failed", "component", "api", "content_id", contentID, "error", err) } }() } @@ -2374,7 +2374,7 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) groups, total, err := h.ScannedGroupRepo.ListByFolder(r.Context(), libraryID, state, limit, offset) if err != nil { - slog.Error("listing scanned groups", "library_id", libraryID, "error", err) + slog.ErrorContext(r.Context(), "listing scanned groups", "component", "api", "library_id", libraryID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list roots") return } @@ -2383,7 +2383,7 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) if h.GroupOverrideRepo != nil { overrides, err := h.GroupOverrideRepo.ListByFolder(r.Context(), libraryID) if err != nil { - slog.Warn("listing group overrides", "library_id", libraryID, "error", err) + slog.WarnContext(r.Context(), "listing group overrides", "component", "api", "library_id", libraryID, "error", err) } else { for _, override := range overrides { overrideByGroup[groupOverrideLookupKey(override.GroupKeyVersion, override.ContentGroupKey)] = override @@ -2399,14 +2399,14 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) WHERE media_folder_id = $1 `, libraryID) if err != nil { - slog.Warn("listing group claims", "library_id", libraryID, "error", err) + slog.WarnContext(r.Context(), "listing group claims", "component", "api", "library_id", libraryID, "error", err) } else { defer claimRows.Close() for claimRows.Next() { var version int var groupKey, contentID string if err := claimRows.Scan(&version, &groupKey, &contentID); err != nil { - slog.Warn("scanning group claim", "library_id", libraryID, "error", err) + slog.WarnContext(r.Context(), "scanning group claim", "component", "api", "library_id", libraryID, "error", err) break } contentIDByGroup[groupOverrideLookupKey(version, groupKey)] = contentID @@ -2475,7 +2475,7 @@ func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http } location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath) if err != nil { - slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + slog.ErrorContext(r.Context(), "loading observed media location", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root") return } @@ -2508,7 +2508,7 @@ func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http override.UpdatedByUserID = &userID } if err := h.GroupOverrideRepo.Upsert(r.Context(), override); err != nil { - slog.Error("upserting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + slog.ErrorContext(r.Context(), "upserting group override", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save override") return } @@ -2533,7 +2533,7 @@ func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http } location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath) if err != nil { - slog.Error("loading observed media location", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + slog.ErrorContext(r.Context(), "loading observed media location", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root") return } @@ -2547,7 +2547,7 @@ func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http } if err := h.GroupOverrideRepo.Delete(r.Context(), req.LibraryID, location.PrimaryGroupKeyVersion, location.PrimaryContentGroupKey); err != nil { - slog.Error("deleting group override", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) + slog.ErrorContext(r.Context(), "deleting group override", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete override") return } @@ -2630,7 +2630,7 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http var total int if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil { - slog.Error("counting unmatched items", "error", err) + slog.ErrorContext(r.Context(), "counting unmatched items", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items") return } @@ -2655,7 +2655,7 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http rows, err := h.pool.Query(r.Context(), listSQL, listArgs...) if err != nil { - slog.Error("listing unmatched items", "error", err) + slog.ErrorContext(r.Context(), "listing unmatched items", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items") return } @@ -2665,14 +2665,14 @@ func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http for rows.Next() { var item unmatchedItemResponse if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &item.ContentType, &item.Status, &item.LibraryID, &item.LibraryName); err != nil { - slog.Error("scanning unmatched item", "error", err) + slog.ErrorContext(r.Context(), "scanning unmatched item", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan item") return } items = append(items, item) } if err := rows.Err(); err != nil { - slog.Error("iterating unmatched items", "error", err) + slog.ErrorContext(r.Context(), "iterating unmatched items", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate items") return } diff --git a/internal/api/handlers/library_collections.go b/internal/api/handlers/library_collections.go index dfd8728c..da0c9d3d 100644 --- a/internal/api/handlers/library_collections.go +++ b/internal/api/handlers/library_collections.go @@ -131,7 +131,7 @@ func (h *LibraryCollectionHandler) storeBundledTemplatePoster( ) if err != nil || !stored { if err != nil { - slog.Warn("failed to store bundled template poster", + slog.WarnContext(ctx, "failed to store bundled template poster", "component", "api", "collection_id", collectionID, "poster_path", posterPath, "error", err, @@ -151,7 +151,7 @@ func (h *LibraryCollectionHandler) storeBundledTemplatePoster( input.PosterFromTemplate = &fromTemplate } if err := h.repo.Update(ctx, input); err != nil { - slog.Warn("failed to persist bundled template poster", + slog.WarnContext(ctx, "failed to persist bundled template poster", "component", "api", "collection_id", collectionID, "poster_path", posterPath, "stored_path", storedPath, @@ -175,7 +175,7 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, return collage.ErrNotEnoughImages } - slog.Info("collage: generating poster", "collection_id", collectionID, "item_poster_count", len(paths)) + slog.InfoContext(ctx, "collage: generating poster", "component", "api", "collection_id", collectionID, "item_poster_count", len(paths)) // Resolve poster paths to fetchable URLs. resolved := h.detailSvc.PresignImageURLs(ctx, paths, "poster", "small") @@ -191,14 +191,14 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, } data, err := h.fetchImageURL(ctx, url) if err != nil { - slog.Debug("collage: failed to fetch poster image", "url", url, "error", err) + slog.DebugContext(ctx, "collage: failed to fetch poster image", "component", "api", "url", url, "error", err) fetchFailed++ continue } imageData = append(imageData, data) } if fetchFailed > 0 && len(imageData) == 0 { - slog.Warn("collage: all poster image fetches failed", + slog.WarnContext(ctx, "collage: all poster image fetches failed", "component", "api", "collection_id", collectionID, "total", len(paths), "failed", fetchFailed) } @@ -210,7 +210,7 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, // Delete any existing auto-generated images before uploading new ones. if err := h.deleteCollectionImages(ctx, collectionID, "poster"); err != nil { - slog.Warn("collage: failed to clean up old poster images", "collection_id", collectionID, "error", err) + slog.WarnContext(ctx, "collage: failed to clean up old poster images", "component", "api", "collection_id", collectionID, "error", err) } // Process through the standard image pipeline (generates WebP variants + thumbhash). @@ -231,7 +231,7 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, return fmt.Errorf("updating collection poster: %w", err) } - slog.Info("collage: poster generated successfully", "collection_id", collectionID, "s3_path", s3Path) + slog.InfoContext(ctx, "collage: poster generated successfully", "component", "api", "collection_id", collectionID, "s3_path", s3Path) return nil } @@ -1372,9 +1372,9 @@ func (h *LibraryCollectionHandler) HandleDeleteCollectionImage(w http.ResponseWr if imageType == "poster" && h.service.CollageGen != nil { if err := h.GenerateCollectionPoster(r.Context(), collectionID); err != nil { if errors.Is(err, collage.ErrNotEnoughImages) { - slog.Debug("collage: not enough images to regenerate after delete", "collection_id", collectionID) + slog.DebugContext(r.Context(), "collage: not enough images to regenerate after delete", "component", "api", "collection_id", collectionID) } else { - slog.Warn("collage: failed to regenerate poster after delete", "collection_id", collectionID, "error", err) + slog.WarnContext(r.Context(), "collage: failed to regenerate poster after delete", "component", "api", "collection_id", collectionID, "error", err) } } } @@ -1420,12 +1420,12 @@ func (h *LibraryCollectionHandler) deleteServerCollection(ctx context.Context, c prefix := fmt.Sprintf("collection-images/%s/", collectionID) keys, err := h.s3GP.ListObjects(ctx, h.s3GP.Bucket(), prefix) if err != nil { - slog.Warn("collection delete: listing S3 images failed; image keys may leak", + slog.WarnContext(ctx, "collection delete: listing S3 images failed; image keys may leak", "component", "api", "collection_id", collectionID, "prefix", prefix, "error", err) } for _, key := range keys { if err := h.s3GP.DeleteObject(ctx, h.s3GP.Bucket(), key); err != nil { - slog.Warn("collection delete: removing S3 image failed; key leaks", + slog.WarnContext(ctx, "collection delete: removing S3 image failed; key leaks", "component", "api", "collection_id", collectionID, "key", key, "error", err) } } @@ -1569,7 +1569,7 @@ func (h *LibraryCollectionHandler) HandleSyncAdminCollection(w http.ResponseWrit writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - slog.Error("collection sync failed", "collection_id", collectionID, "error", err) + slog.ErrorContext(r.Context(), "collection sync failed", "component", "api", "collection_id", collectionID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync collection") return } @@ -1817,7 +1817,7 @@ func (h *LibraryCollectionHandler) applyTemplateBundle( Reason: err.Error(), }) } else { - slog.Warn("listing existing collections before template bundle apply", + slog.WarnContext(ctx, "listing existing collections before template bundle apply", "component", "api", "bundle_id", bundle.ID, "library_id", library.ID, "error", err, @@ -1839,7 +1839,7 @@ func (h *LibraryCollectionHandler) applyTemplateBundle( // trips the in-use guard and the admin sees a misleading wall of // "collection_in_use" errors. if err := h.SectionRepo.DeleteGeneratedTemplateBundleFeaturedSections(ctx, bundle.ID, libraryIDs); err != nil { - slog.Error("deleting generated template bundle featured sections", "bundle_id", bundle.ID, "error", err) + slog.ErrorContext(ctx, "deleting generated template bundle featured sections", "component", "api", "bundle_id", bundle.ID, "error", err) return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusInternalServerError, "delete_setup_failed", "Failed to clear generated featured sections before delete") } } @@ -2060,7 +2060,7 @@ func (h *LibraryCollectionHandler) ensureTemplatePoster( return } if stored, _, _, err := h.storeBundledTemplatePoster(ctx, collection.ID, posterPath, true); err != nil { - slog.Warn("failed to store template poster", + slog.WarnContext(ctx, "failed to store template poster", "component", "api", "collection_id", collection.ID, "template_id", tmpl.ID, "poster_path", posterPath, @@ -2081,7 +2081,7 @@ func (h *LibraryCollectionHandler) ensureTemplatePoster( PosterAutoGenerated: ¬AutoGenerated, PosterFromTemplate: &fromTemplate, }); err != nil { - slog.Warn("failed to apply template poster", + slog.WarnContext(ctx, "failed to apply template poster", "component", "api", "collection_id", collection.ID, "template_id", tmpl.ID, "poster_path", posterPath, @@ -2188,7 +2188,7 @@ func (h *LibraryCollectionHandler) advanceTemplateBundleSyncSchedules( } next := catalog.ComputeNextSyncAtFrom(*item.SyncSchedule, now) if err := h.repo.UpdateNextSyncAt(ctx, item.CollectionID, next); err != nil { - slog.Warn("template bundle async sync: failed to advance schedule", + slog.WarnContext(ctx, "template bundle async sync: failed to advance schedule", "component", "api", "collection_id", item.CollectionID, "template_id", item.Entry.TemplateID, "error", err, @@ -2213,7 +2213,7 @@ func (h *LibraryCollectionHandler) syncTemplateBundleCollections( eg.Go(func() (err error) { defer func() { if r := recover(); r != nil { - slog.Error("template bundle sync worker panic", + slog.ErrorContext(ctx, "template bundle sync worker panic", "component", "api", "collection_id", item.CollectionID, "template_id", item.Entry.TemplateID, "panic", r, @@ -3081,7 +3081,7 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr run, err := h.service.SyncCollection(r.Context(), collection.ID) if err != nil { - slog.Error("failed to sync imported Trakt collection", + slog.ErrorContext(r.Context(), "failed to sync imported Trakt collection", "component", "api", "collection_id", collection.ID, "library_id", req.LibraryID, "title", req.Title, diff --git a/internal/api/handlers/markers.go b/internal/api/handlers/markers.go index 64965729..b4a0a9a7 100644 --- a/internal/api/handlers/markers.go +++ b/internal/api/handlers/markers.go @@ -202,7 +202,7 @@ func (h *MarkersHandler) authorizeFile(w http.ResponseWriter, r *http.Request, f case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound): writeError(w, http.StatusNotFound, "not_found", "Media file not found") default: - h.logger.Error("markers: authorize failed", "file_id", fileID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: authorize failed", "file_id", fileID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file") } return nil, false @@ -230,14 +230,14 @@ func (h *MarkersHandler) loadItemPrimaryFile(w http.ResponseWriter, r *http.Requ files, err := h.Files.GetByEpisodeID(r.Context(), itemID) if err != nil { - h.logger.Error("markers: episode file lookup failed", "item_id", itemID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: episode file lookup failed", "item_id", itemID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") return nil, false } if len(files) == 0 { files, err = h.Files.GetByContentID(r.Context(), itemID) if err != nil { - h.logger.Error("markers: content file lookup failed", "item_id", itemID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: content file lookup failed", "item_id", itemID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") return nil, false } @@ -260,7 +260,7 @@ func (h *MarkersHandler) loadItemPrimaryFile(w http.ResponseWriter, r *http.Requ if errors.Is(err, catalog.ErrItemNotFound) || errors.Is(err, catalog.ErrEpisodeNotFound) { continue } - h.logger.Error("markers: authorize item file failed", "item_id", itemID, "file_id", file.ID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: authorize item file failed", "item_id", itemID, "file_id", file.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file") return nil, false } @@ -377,14 +377,14 @@ func (h *MarkersHandler) setMarkersForFile(w http.ResponseWriter, r *http.Reques } if _, err := h.Writer.UpsertAndClearMarkers(h.auditContext(r), file.ID, update, clears); err != nil { - h.logger.Error("markers: save failed", "file_id", file.ID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: save failed", "file_id", file.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save markers") return } refreshed, err := h.reloadAndNotify(r.Context(), file.ID) if err != nil { - h.logger.Error("markers: reload after save failed", "file_id", file.ID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: reload after save failed", "file_id", file.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Markers saved but failed to reload") return } @@ -408,13 +408,13 @@ func (h *MarkersHandler) HandleClearFileSegment(w http.ResponseWriter, r *http.R return } if _, err := h.Writer.ClearMarkers(h.auditContext(r), file.ID, []string{segment}); err != nil { - h.logger.Error("markers: clear segment failed", "file_id", file.ID, "segment", segment, "error", err) + h.logger.ErrorContext(r.Context(), "markers: clear segment failed", "file_id", file.ID, "segment", segment, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear marker") return } refreshed, err := h.reloadAndNotify(r.Context(), file.ID) if err != nil { - h.logger.Error("markers: reload after clear failed", "file_id", file.ID, "segment", segment, "error", err) + h.logger.ErrorContext(r.Context(), "markers: reload after clear failed", "file_id", file.ID, "segment", segment, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Marker cleared but failed to reload") return } @@ -450,7 +450,7 @@ func (h *MarkersHandler) HandleContributeFile(w http.ResponseWriter, r *http.Req } outcomes, err := h.Contributor.ContributeFile(r.Context(), file, markers.ContributeOptions{Provider: body.Provider, Segments: kinds}) if err != nil { - h.logger.Error("markers: contribute failed", "file_id", file.ID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: contribute failed", "file_id", file.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Contribution failed") return } @@ -469,7 +469,7 @@ func (h *MarkersHandler) HandleListFileContributions(w http.ResponseWriter, r *h } rows, err := h.Contributions.ListByFile(r.Context(), file.ID) if err != nil { - h.logger.Error("markers: list contributions failed", "file_id", file.ID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: list contributions failed", "file_id", file.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load contributions") return } @@ -499,14 +499,14 @@ func (h *MarkersHandler) HandleListItemMarkerHistory(w http.ResponseWriter, r *h files, err := h.Files.GetByEpisodeID(r.Context(), itemID) if err != nil { - h.logger.Error("markers: episode history file lookup failed", "item_id", itemID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: episode history file lookup failed", "item_id", itemID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") return } if len(files) == 0 { files, err = h.Files.GetByContentID(r.Context(), itemID) if err != nil { - h.logger.Error("markers: content history file lookup failed", "item_id", itemID, "error", err) + h.logger.ErrorContext(r.Context(), "markers: content history file lookup failed", "item_id", itemID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") return } @@ -537,7 +537,7 @@ func (h *MarkersHandler) HandleListMarkerHistory(w http.ResponseWriter, r *http. } rows, err := h.AuditHistory.ListAllMarkerEditAudit(r.Context(), limit) if err != nil { - h.logger.Error("markers: list all history failed", "error", err) + h.logger.ErrorContext(r.Context(), "markers: list all history failed", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history") return } @@ -555,7 +555,7 @@ func (h *MarkersHandler) listMarkerHistory(w http.ResponseWriter, r *http.Reques } rows, err := h.AuditHistory.ListMarkerEditAudit(r.Context(), fileIDs, limit) if err != nil { - h.logger.Error("markers: list history failed", "file_ids", fileIDs, "error", err) + h.logger.ErrorContext(r.Context(), "markers: list history failed", "file_ids", fileIDs, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history") return } diff --git a/internal/api/handlers/metadata_ai.go b/internal/api/handlers/metadata_ai.go index cbc28c9f..468ec608 100644 --- a/internal/api/handlers/metadata_ai.go +++ b/internal/api/handlers/metadata_ai.go @@ -138,7 +138,7 @@ func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http case errors.Is(err, translation.ErrInvalidRequest): writeError(w, http.StatusBadRequest, "bad_request", err.Error()) default: - slog.Error("failed to request on-view translation", + slog.ErrorContext(r.Context(), "failed to request on-view translation", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation") } @@ -247,7 +247,7 @@ func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque case errors.Is(err, translation.ErrInvalidRequest): writeError(w, http.StatusBadRequest, "bad_request", err.Error()) default: - slog.Error("failed to enqueue metadata translation", + slog.ErrorContext(r.Context(), "failed to enqueue metadata translation", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation") } diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go index 76ec84da..79690c90 100644 --- a/internal/api/handlers/nodes.go +++ b/internal/api/handlers/nodes.go @@ -77,7 +77,7 @@ type checkNodeResult struct { func (h *NodeHandler) HandleListNodes(w http.ResponseWriter, r *http.Request) { nodes, err := h.repo.List(r.Context()) if err != nil { - slog.Error("listing nodes", "error", err) + slog.ErrorContext(r.Context(), "listing nodes", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list nodes") return } @@ -102,7 +102,7 @@ func (h *NodeHandler) HandleCreateNode(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - slog.Error("creating node", "error", err) + slog.ErrorContext(r.Context(), "creating node", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create node") return } @@ -131,7 +131,7 @@ func (h *NodeHandler) HandleUpdateNode(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "Node not found") return } - slog.Error("updating node", "error", err) + slog.ErrorContext(r.Context(), "updating node", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update node") return } @@ -154,7 +154,7 @@ func (h *NodeHandler) HandleDeleteNode(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "Node not found") return } - slog.Error("deleting node", "error", err) + slog.ErrorContext(r.Context(), "deleting node", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete node") return } @@ -177,7 +177,7 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "Node not found") return } - slog.Error("fetching node for check", "error", err) + slog.ErrorContext(r.Context(), "fetching node for check", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch node") return } @@ -185,7 +185,7 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) { healthy, activeJobs, egressKbps := nodepool.CheckNode(r.Context(), node) if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs, egressKbps); err != nil { - slog.Error("persisting health check result", "node_id", id, "error", err) + slog.ErrorContext(r.Context(), "persisting health check result", "component", "api", "node_id", id, "error", err) } writeJSON(w, http.StatusOK, checkNodeResult{ @@ -362,7 +362,7 @@ func (h *NodeHandler) reloadPools(ctx context.Context) { proxyNodes, proxyErr := h.lister.ListEnabled(ctx, nodepool.NodeTypeProxy) transcodeNodes, tcErr := h.lister.ListEnabled(ctx, nodepool.NodeTypeTranscode) if proxyErr != nil || tcErr != nil { - slog.Warn("node pool reload failed", "proxy_err", proxyErr, "transcode_err", tcErr) + slog.WarnContext(ctx, "node pool reload failed", "component", "api", "proxy_err", proxyErr, "transcode_err", tcErr) return } if h.proxyPool != nil { diff --git a/internal/api/handlers/people.go b/internal/api/handlers/people.go index 43b3f5ed..eee65f3b 100644 --- a/internal/api/handlers/people.go +++ b/internal/api/handlers/people.go @@ -128,7 +128,7 @@ func (h *PeopleHandler) HandleGetPerson(w http.ResponseWriter, r *http.Request) person, err := h.personRepo.Get(r.Context(), id) if err != nil { - slog.Warn("people: get person failed", "id", id, "id_str", idStr, "error", err) + slog.WarnContext(r.Context(), "people: get person failed", "component", "api", "id", id, "id_str", idStr, "error", err) writeError(w, http.StatusNotFound, "not_found", "person not found") return } @@ -201,7 +201,7 @@ func (h *PeopleHandler) HandleAdminRefreshPerson(w http.ResponseWriter, r *http. case errors.Is(err, metadata.ErrPersonMetadataNotFound): writeError(w, http.StatusBadGateway, "provider_error", "No person metadata found") default: - slog.Warn("people: admin refresh failed", "id", id, "error", err) + slog.WarnContext(r.Context(), "people: admin refresh failed", "component", "api", "id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to refresh person") } return @@ -281,7 +281,7 @@ func (h *PeopleHandler) HandleAdminUpdatePerson(w http.ResponseWriter, r *http.R } if err := h.personRepo.Update(r.Context(), *person); err != nil { - slog.Error("people: admin update failed", "id", id, "error", err) + slog.ErrorContext(r.Context(), "people: admin update failed", "component", "api", "id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update person") return } diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 6bf5bb40..38712afa 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -222,7 +222,7 @@ func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathReso return } if err := h.stopPlaybackSessionByID(ctx, sessionID, false); err != nil && !errors.Is(err, playback.ErrSessionNotFound) { - slog.Error("failed to stop playback after local transcode exit", "session", sessionID, "error", err, "playback_session_id", sessionID) + slog.ErrorContext(ctx, "failed to stop playback after local transcode exit", "component", "api", "session", sessionID, "error", err, "playback_session_id", sessionID) } } if reg, ok := sessionMgr.(interface { @@ -496,7 +496,7 @@ func (h *PlaybackHandler) ensurePlaybackProbe(ctx context.Context, file *models. } repaired, err := h.ProbeEnsurer.Ensure(ctx, file) if err != nil { - slog.Warn("playback probe repair failed", "file_id", file.ID, "path", file.FilePath, "error", err) + slog.WarnContext(ctx, "playback probe repair failed", "component", "api", "file_id", file.ID, "path", file.FilePath, "error", err) return file } if repaired != nil { @@ -921,13 +921,13 @@ func (h *PlaybackHandler) restoreSessionProgress( store, err := h.StoreProvider.ForUser(ctx, session.UserID) if err != nil { - slog.Error("failed to get user store", "user_id", session.UserID, "error", err) + slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err) return } progress, err := store.GetProgress(ctx, session.ProfileID, targetID) if err != nil { - slog.Error("failed to load progress", "target", targetID, "error", err) + slog.ErrorContext(ctx, "failed to load progress", "component", "api", "target", targetID, "error", err) return } @@ -936,7 +936,7 @@ func (h *PlaybackHandler) restoreSessionProgress( } if err := h.sessionMgr.UpdateProgress(session.ID, progress.PositionSeconds, false); err != nil { - slog.Error("failed to restore progress", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to restore progress", "component", "api", "session", session.ID, "error", err) return } @@ -972,13 +972,13 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback store, err := h.StoreProvider.ForUser(ctx, session.UserID) if err != nil { - slog.Error("failed to get user store", "user_id", session.UserID, "error", err) + slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err) return } duration := float64(file.Duration) if err := store.UpdateProgress(ctx, session.ProfileID, targetID, session.Position, duration, h.playbackThresholds(ctx)); err != nil { - slog.Error("failed to persist progress", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to persist progress", "component", "api", "session", session.ID, "error", err) } else { triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID) } @@ -990,7 +990,7 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback CodecVideo: file.CodecVideo, EditionKey: file.EditionKey, }); err != nil { - slog.Error("failed to persist version hints", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to persist version hints", "component", "api", "session", session.ID, "error", err) } } @@ -1025,7 +1025,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl EditionKey: file.EditionKey, }, thresholds) if err != nil { - slog.Error("failed to persist playback stop", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to persist playback stop", "component", "api", "session", session.ID, "error", err) } else { triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID) } @@ -1091,11 +1091,11 @@ func (h *PlaybackHandler) buildAdminHistoryEntry( if h.StoreProvider != nil { store, storeErr := h.StoreProvider.ForUser(ctx, session.UserID) if storeErr != nil { - slog.Error("failed to get user store for admin history", "session", session.ID, "error", storeErr) + slog.ErrorContext(ctx, "failed to get user store for admin history", "component", "api", "session", session.ID, "error", storeErr) } else if store != nil { profile, profileErr := store.GetProfile(ctx, session.ProfileID) if profileErr != nil { - slog.Error("failed to load profile for admin history", "session", session.ID, "error", profileErr) + slog.ErrorContext(ctx, "failed to load profile for admin history", "component", "api", "session", session.ID, "error", profileErr) } else if profile != nil && strings.TrimSpace(profile.Name) != "" { profileName = profile.Name } @@ -1135,7 +1135,7 @@ func (h *PlaybackHandler) syncSessionsNow(ctx context.Context, reason string) { return } if err := h.SessionSyncer.SyncNow(ctx); err != nil { - slog.Error("failed to sync sessions", "reason", reason, "error", err) + slog.ErrorContext(ctx, "failed to sync sessions", "component", "api", "reason", reason, "error", err) } } @@ -1163,25 +1163,25 @@ func (h *PlaybackHandler) finalizeSessionStop(ctx context.Context, session *play event.Completed = stopResult.Completed if stopResult.Completed { if err := h.WatchScrobbler.ScrobbleStop(ctx, event); err != nil { - slog.Warn("failed to queue watch provider stop scrobble", "session", session.ID, "error", err) + slog.WarnContext(ctx, "failed to queue watch provider stop scrobble", "component", "api", "session", session.ID, "error", err) } } else if !stopResult.SkippedBelowMinResume { if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil { - slog.Warn("failed to queue watch provider pause scrobble", "session", session.ID, "error", err) + slog.WarnContext(ctx, "failed to queue watch provider pause scrobble", "component", "api", "session", session.ID, "error", err) } } } if entry, buildErr := h.buildAdminHistoryEntry(ctx, session); buildErr != nil { - slog.Error("failed to build admin history", "session", session.ID, "error", buildErr) + slog.ErrorContext(ctx, "failed to build admin history", "component", "api", "session", session.ID, "error", buildErr) } else if entry != nil && h.AdminStore != nil { if err := h.AdminStore.RecordHistory(ctx, *entry); err != nil { - slog.Error("failed to record admin history", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to record admin history", "component", "api", "session", session.ID, "error", err) } } if h.AdminStore != nil { if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil { - slog.Error("failed to delete synced session", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err) } } @@ -1205,7 +1205,7 @@ func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *pla if targetID != "" { event := h.scrobbleEventForSession(ctx, session, targetID, float64(file.Duration), session.Position) if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil { - slog.Warn("failed to queue watch provider abort scrobble", "session", session.ID, "error", err) + slog.WarnContext(ctx, "failed to queue watch provider abort scrobble", "component", "api", "session", session.ID, "error", err) } } } @@ -1213,7 +1213,7 @@ func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *pla if h.AdminStore != nil { if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil { - slog.Error("failed to delete synced session", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err) } } @@ -1266,7 +1266,7 @@ func (h *PlaybackHandler) persistSeriesPlaybackPreference( store, err := h.StoreProvider.ForUser(ctx, userID) if err != nil { - slog.Error("failed to access user store for series playback preference", "user_id", userID, "error", err) + slog.ErrorContext(ctx, "failed to access user store for series playback preference", "component", "api", "user_id", userID, "error", err) return } @@ -1277,7 +1277,7 @@ func (h *PlaybackHandler) persistSeriesPlaybackPreference( HDR: file.HDR, CodecVideo: file.CodecVideo, }); err != nil { - slog.Error("failed to persist series playback preference", "series_id", seriesID, "profile_id", profileID, "error", err) + slog.ErrorContext(ctx, "failed to persist series playback preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err) } } @@ -1299,7 +1299,7 @@ func (h *PlaybackHandler) persistAudioPreference( store, err := h.StoreProvider.ForUser(ctx, userID) if err != nil { - slog.Error("failed to access user store for audio preference", "user_id", userID, "error", err) + slog.ErrorContext(ctx, "failed to access user store for audio preference", "component", "api", "user_id", userID, "error", err) return } @@ -1311,7 +1311,7 @@ func (h *PlaybackHandler) persistAudioPreference( AudioLanguage: track.Language, TrackSignature: playback.AudioTrackSignatureFromTrack(track), }); err != nil { - slog.Error("failed to persist audio preference", "series_id", seriesID, "profile_id", profileID, "error", err) + slog.ErrorContext(ctx, "failed to persist audio preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err) } } @@ -1444,7 +1444,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req ) if requestedFile != nil && effectiveFile != nil && requestedFile.ID != effectiveFile.ID { if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) { - slog.Warn("requested playback file preflight failed; continuing with alternate file", + slog.WarnContext(r.Context(), "requested playback file preflight failed; continuing with alternate file", "component", "api", "requested_file_id", requestedFile.ID, "effective_file_id", effectiveFile.ID, "error", err, @@ -1498,14 +1498,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req setPlaybackSessionLogContext(r, session.ID) if req.DisableProgressPersistence { if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil { - slog.Error("failed to disable progress persistence", "session", session.ID, "error", err) + slog.ErrorContext(r.Context(), "failed to disable progress persistence", "component", "api", "session", session.ID, "error", err) } else { session.DisableProgressPersistence = true } } if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil { - slog.Error("failed to set audio track", "session", session.ID, "error", err) + slog.ErrorContext(r.Context(), "failed to set audio track", "component", "api", "session", session.ID, "error", err) } targetAudioCodec := "" if session.TranscodeAudio { @@ -1527,7 +1527,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req StreamBitrateKbps: streamBitrateKbps, TargetAudioCodec: targetAudioCodec, }); err != nil { - slog.Error("failed to set stream state", "session", session.ID, "error", err) + slog.ErrorContext(r.Context(), "failed to set stream state", "component", "api", "session", session.ID, "error", err) } session.AudioTrackIndex = audioTrackIndex session.ClientIP = clientip.FromContext(r.Context()) @@ -1537,7 +1537,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req if req.StartPosition != nil { if err := h.sessionMgr.UpdateProgress(session.ID, *req.StartPosition, false); err != nil { - slog.Error("failed to set explicit start position", "session", session.ID, "error", err) + slog.ErrorContext(r.Context(), "failed to set explicit start position", "component", "api", "session", session.ID, "error", err) } else { session.Position = *req.StartPosition session.IsPaused = false @@ -1550,13 +1550,13 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req if targetID != "" { event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position) if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil { - slog.Warn("failed to queue watch provider start scrobble", "session", session.ID, "error", err) + slog.WarnContext(r.Context(), "failed to queue watch provider start scrobble", "component", "api", "session", session.ID, "error", err) } } } if h.ChapterThumbnailQueuer != nil && effectiveFile != nil { - slog.Info( - "queueing chapter thumbnails", + slog.InfoContext(r.Context(), + "queueing chapter thumbnails", "component", "api", "source", "playback_start", "content_id", @@ -1808,10 +1808,10 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re event := h.scrobbleEventForSession(r.Context(), sess, targetID, float64(file.Duration), sess.Position) if sess.IsPaused { if err := h.WatchScrobbler.ScrobblePause(r.Context(), event); err != nil { - slog.Warn("failed to queue watch provider pause scrobble", "session", sessionID, "error", err) + slog.WarnContext(r.Context(), "failed to queue watch provider pause scrobble", "component", "api", "session", sessionID, "error", err) } } else if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil { - slog.Warn("failed to queue watch provider resume scrobble", "session", sessionID, "error", err) + slog.WarnContext(r.Context(), "failed to queue watch provider resume scrobble", "component", "api", "session", sessionID, "error", err) } } } @@ -1946,7 +1946,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. } else if transcodeAudio { targetAudioCodec = "aac" } - slog.Info("audio switch computed playback state", + slog.InfoContext(r.Context(), "audio switch computed playback state", "component", "api", "playback_session_id", sessionID, "previous_base_play_method", baseMethod, "new_base_play_method", newMethod, @@ -1989,7 +1989,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. startSegment := computeStartSegment(seekSeconds, ts.Opts().SegmentDuration) // Throttler + exit monitor re-arm via the session's restart hook. if restartErr := h.tm.RestartSessionLocked(context.WithoutCancel(r.Context()), sessionID, ts, seekSeconds, startSegment); restartErr != nil { - slog.Error("failed to restart transcode for audio switch", "session", sessionID, "error", restartErr) + slog.ErrorContext(r.Context(), "failed to restart transcode for audio switch", "component", "api", "session", sessionID, "error", restartErr) } } } @@ -2109,7 +2109,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. defer cancel() httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, nodeURL+"/transcode/start", bytes.NewReader(body)) if reqErr != nil { - slog.Error("failed to build remote transcode restart for audio switch", "session", sessionID, "node", nodeURL, "error", reqErr) + slog.ErrorContext(r.Context(), "failed to build remote transcode restart for audio switch", "component", "api", "session", sessionID, "node", nodeURL, "error", reqErr) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build transcode request") return } @@ -2118,20 +2118,20 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. nodeResp, doErr := http.DefaultClient.Do(httpReq) if doErr != nil { - slog.Error("remote transcode restart for audio switch failed", "session", sessionID, "node", nodeURL, "error", doErr) + slog.ErrorContext(r.Context(), "remote transcode restart for audio switch failed", "component", "api", "session", sessionID, "node", nodeURL, "error", doErr) writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable") return } defer nodeResp.Body.Close() if nodeResp.StatusCode != http.StatusAccepted { - slog.Error("remote transcode restart for audio switch rejected", "session", sessionID, "node", nodeURL, "status", nodeResp.StatusCode) + slog.ErrorContext(r.Context(), "remote transcode restart for audio switch rejected", "component", "api", "session", sessionID, "node", nodeURL, "status", nodeResp.StatusCode) writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request") return } var startResp transcodenode.TranscodeStartResponse if decErr := json.NewDecoder(nodeResp.Body).Decode(&startResp); decErr != nil { - slog.Warn("remote transcode restart response decode failed", "session", sessionID, "node", nodeURL, "error", decErr) + slog.WarnContext(r.Context(), "remote transcode restart response decode failed", "component", "api", "session", sessionID, "node", nodeURL, "error", decErr) } effectiveHWAccel := strings.TrimSpace(startResp.HWAccel) if effectiveHWAccel == "" { @@ -2281,7 +2281,7 @@ type transcodeStartState struct { func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeStartState) { if st.switchedFileID != nil { if err := h.sessionMgr.SetEffectiveMediaFileID(st.req.SessionID, *st.switchedFileID); err != nil { - slog.Error("failed to update effective media file", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) + slog.ErrorContext(r.Context(), "failed to update effective media file", "component", "api", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) } } @@ -2292,7 +2292,7 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt transcodeAudio := st.req.TargetCodecAudio != "" && !strings.EqualFold(st.req.TargetCodecAudio, "copy") baseMethod := semanticPlayMethod(st.session) - slog.Info("transcode start preserved base playback state", + slog.InfoContext(r.Context(), "transcode start preserved base playback state", "component", "api", "playback_session_id", st.req.SessionID, "base_play_method", baseMethod, "transport_play_method", playback.PlayTranscode, @@ -2328,7 +2328,7 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt SubtitleBurnIn: st.req.SubtitleBurnIn, SegmentDuration: segmentDuration, }); err != nil { - slog.Error("failed to update transcode stream state", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) + slog.ErrorContext(r.Context(), "failed to update transcode stream state", "component", "api", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) } h.syncSessionsNow(r.Context(), "transcode_start") @@ -2377,7 +2377,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re } abortCurrentSession := func(reason string, cause error) { if abortErr := h.abortPlaybackSession(r.Context(), session); abortErr != nil && !errors.Is(abortErr, playback.ErrSessionNotFound) { - slog.Error("failed to abort playback session", + slog.ErrorContext(r.Context(), "failed to abort playback session", "component", "api", "session", req.SessionID, "reason", reason, "cause", cause, @@ -2411,7 +2411,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re // advancing. MPEG-2 compatibility HLS is allowed to keep copy-video so Apple // devices can avoid a full video transcode for those files. if req.SeekSeconds > 0 && strings.EqualFold(req.TargetCodecVideo, "copy") && !playback.IsMPEG2VideoCodec(file.CodecVideo) { - slog.Info("forcing video transcode for seeked copy request", + slog.InfoContext(r.Context(), "forcing video transcode for seeked copy request", "component", "api", "playback_session_id", req.SessionID, "seek_seconds", req.SeekSeconds, "source_video_codec", file.CodecVideo, @@ -2443,7 +2443,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re } if requestedFile != nil && file != nil && requestedFile.ID != file.ID { if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) { - slog.Warn("requested transcode file preflight failed; continuing with alternate file", + slog.WarnContext(r.Context(), "requested transcode file preflight failed; continuing with alternate file", "component", "api", "requested_file_id", requestedFile.ID, "effective_file_id", file.ID, "error", err, @@ -2472,7 +2472,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re if tcNode != nil { // Remote transcode: forward to the assigned node. if err := h.sessionMgr.SetTranscodeNodeURL(req.SessionID, tcNode.URL); err != nil { - slog.Error("set transcode node URL", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) + slog.ErrorContext(r.Context(), "set transcode node URL", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) } nodeReq := transcodenode.TranscodeStartRequest{ @@ -2507,20 +2507,20 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re resp, err := http.DefaultClient.Do(httpReq) if err != nil { - slog.Error("remote transcode start failed", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID) + slog.ErrorContext(r.Context(), "remote transcode start failed", "component", "api", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID) writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable") return } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { - slog.Error("remote transcode start rejected", "status", resp.StatusCode, "node", tcNode.URL) + slog.ErrorContext(r.Context(), "remote transcode start rejected", "component", "api", "status", resp.StatusCode, "node", tcNode.URL) writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request") return } var nodeResp transcodenode.TranscodeStartResponse if err := json.NewDecoder(resp.Body).Decode(&nodeResp); err != nil { - slog.Warn("remote transcode start response decode failed", + slog.WarnContext(r.Context(), "remote transcode start response decode failed", "component", "api", "error", err, "node", tcNode.URL, "session", req.SessionID, @@ -2702,7 +2702,7 @@ func (h *PlaybackHandler) HandleGetTranscodeManifest(w http.ResponseWriter, r *h manifest, err := transcodeSession.BuildPlaybackManifest("segment/", r.URL.RawQuery) if err != nil { - slog.Error("build transcode manifest", "error", err, "session", sessionID, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "build transcode manifest", "component", "api", "error", err, "session", sessionID, "playback_session_id", sessionID) writeError(w, http.StatusServiceUnavailable, "unavailable", "Transcode manifest not ready") return } @@ -2770,7 +2770,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht if !decision.Progress.LastProducedAt.IsZero() { lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds() } - slog.Info("transcode segment missing", + slog.InfoContext(r.Context(), "transcode segment missing", "component", "api", "segment", segmentName, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -2784,7 +2784,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht "playback_session_id", sessionID, ) if decision.Wait { - slog.Info("transcode segment wait", + slog.InfoContext(r.Context(), "transcode segment wait", "component", "api", "segment", segmentName, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -2799,7 +2799,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht ) segmentPath, err = transcodeSession.WaitForSegment(segmentName, decision.WaitTimeout) if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { - slog.Info("transcode segment wait timeout", + slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "api", "segment", segmentName, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -2822,7 +2822,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht if err != nil && errors.Is(err, playback.ErrSegmentNotFound) && decision.RestartOnTimeout { seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum) if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) { - slog.Error("resolve transcode seek target", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "resolve transcode seek target", "component", "api", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID) } // Copy-mode with an unresolved seek target (ok=false, no error) @@ -2836,7 +2836,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht } if ok { - slog.Info("transcode seek restart", + slog.InfoContext(r.Context(), "transcode seek restart", "component", "api", "segment", segmentName, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -2949,13 +2949,13 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re req.Header.Set("X-Silo-Stream-Token", stToken) validToken = stToken } else if verifyErr != nil { - slog.Warn("stream token not forwarded to transcode node", "error", verifyErr, "playback_session_id", sessionID) + slog.WarnContext(r.Context(), "stream token not forwarded to transcode node", "component", "api", "error", verifyErr, "playback_session_id", sessionID) } } resp, err := http.DefaultClient.Do(req) if err != nil { - slog.Error("proxy to transcode node", "error", err, "url", targetURL, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "proxy to transcode node", "component", "api", "error", err, "url", targetURL, "playback_session_id", sessionID) http.Error(w, "transcode node unavailable", http.StatusBadGateway) return } @@ -2970,7 +2970,7 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re if validToken != "" && resp.StatusCode == http.StatusOK && strings.HasSuffix(path, ".m3u8") { body, readErr := io.ReadAll(resp.Body) if readErr != nil { - slog.Error("read transcode node manifest", "error", readErr, "url", targetURL, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "read transcode node manifest", "component", "api", "error", readErr, "url", targetURL, "playback_session_id", sessionID) http.Error(w, "transcode node unavailable", http.StatusBadGateway) return } diff --git a/internal/api/handlers/playback_lazy_markers.go b/internal/api/handlers/playback_lazy_markers.go index 666ced43..98f666e1 100644 --- a/internal/api/handlers/playback_lazy_markers.go +++ b/internal/api/handlers/playback_lazy_markers.go @@ -52,7 +52,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( lazy, err := h.SettingsRepo.Get(ctx, markers.SettingLazyPlayback) if err != nil { - slog.Warn("playback lazy markers: load lazy setting failed", + slog.WarnContext(ctx, "playback lazy markers: load lazy setting failed", "component", "api", "session_id", session.ID, "file_id", file.ID, "episode_id", file.EpisodeID, @@ -65,7 +65,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( rawMode, err := h.SettingsRepo.Get(ctx, markers.SettingMode) if err != nil { - slog.Warn("playback lazy markers: load marker mode failed", + slog.WarnContext(ctx, "playback lazy markers: load marker mode failed", "component", "api", "session_id", session.ID, "file_id", file.ID, "episode_id", file.EpisodeID, @@ -74,7 +74,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( } mode := markers.NormalizeMode(rawMode) if mode == markers.ModeOff { - slog.Debug("playback lazy markers: skipped; marker mode is off", + slog.DebugContext(ctx, "playback lazy markers: skipped; marker mode is off", "component", "api", "session_id", session.ID, "file_id", file.ID, "episode_id", file.EpisodeID) @@ -92,7 +92,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( // Online providers work for any enabled library (movies and series alike). ok, err := h.IntroRepository.IsFileInEnabledLibrary(ctx, file.ID) if err != nil { - slog.Warn("playback lazy markers: online eligibility check failed", + slog.WarnContext(ctx, "playback lazy markers: online eligibility check failed", "component", "api", "session_id", session.ID, "file_id", file.ID, "error", err) @@ -108,7 +108,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( // opted in to expensive fingerprinting and requires an analyzer. ok, err := h.IntroRepository.IntroDetectionEligibleForPlayback(ctx, file.ID) if err != nil { - slog.Warn("playback lazy markers: local eligibility check failed", + slog.WarnContext(ctx, "playback lazy markers: local eligibility check failed", "component", "api", "session_id", session.ID, "file_id", file.ID, "episode_id", file.EpisodeID, @@ -122,7 +122,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( } if !shouldRunOnline && !shouldRunLocal { - slog.Debug("playback lazy markers: skipped; no eligible detection path", + slog.DebugContext(ctx, "playback lazy markers: skipped; no eligible detection path", "component", "api", "session_id", session.ID, "file_id", file.ID, "episode_id", file.EpisodeID, @@ -136,7 +136,7 @@ func (h *PlaybackHandler) maybeQueueLazyPlaybackMarkers( sessionID := session.ID fileSnapshot := *file - slog.Info("playback lazy markers: queued", + slog.InfoContext(ctx, "playback lazy markers: queued", "component", "api", "session_id", sessionID, "file_id", file.ID, "episode_id", file.EpisodeID, @@ -248,7 +248,7 @@ func (h *PlaybackHandler) fetchOnlineMarkersForPlayback(ctx context.Context, fil return false, nil } if h.MarkerResolver == nil || h.MarkerUpserter == nil { - slog.Debug("playback lazy markers: online fetch skipped; resolver or upserter missing", + slog.DebugContext(ctx, "playback lazy markers: online fetch skipped; resolver or upserter missing", "component", "api", "file_id", file.ID) return false, nil } @@ -258,7 +258,7 @@ func (h *PlaybackHandler) fetchOnlineMarkersForPlayback(ctx context.Context, fil return false, err } if !ids.HasAnyID() { - slog.Debug("playback lazy markers: online fetch skipped; no external IDs available", + slog.DebugContext(ctx, "playback lazy markers: online fetch skipped; no external IDs available", "component", "api", "file_id", file.ID, "episode_id", file.EpisodeID, "content_id", file.ContentID) @@ -297,7 +297,7 @@ func (h *PlaybackHandler) reloadPlaybackMarkerFile(ctx context.Context, fileID i } refreshed, err := h.fileResolver.GetByID(ctx, fileID) if err != nil { - slog.Warn("playback lazy markers: reload file failed", "file_id", fileID, "error", err) + slog.WarnContext(ctx, "playback lazy markers: reload file failed", "component", "api", "file_id", fileID, "error", err) return nil } return refreshed @@ -313,7 +313,7 @@ func (h *PlaybackHandler) notifyPlaybackMarkers( return } h.MarkerUpdateNotifier.MarkersUpdated(ctx, file) - slog.Info("playback lazy markers: emitted marker update", + slog.InfoContext(ctx, "playback lazy markers: emitted marker update", "component", "api", "session_id", sessionID, "file_id", file.ID, "episode_id", file.EpisodeID, diff --git a/internal/api/handlers/playback_missing.go b/internal/api/handlers/playback_missing.go index 44690c82..28d2bebe 100644 --- a/internal/api/handlers/playback_missing.go +++ b/internal/api/handlers/playback_missing.go @@ -108,7 +108,7 @@ func markPlaybackFileMissing( if marker != nil { if err := marker.MarkMissing(ctx, file.ID, since); err != nil { - slog.Warn("failed to mark playback file missing", + slog.WarnContext(ctx, "failed to mark playback file missing", "component", "api", "file_id", file.ID, "path", file.FilePath, "error", err, diff --git a/internal/api/handlers/plugins.go b/internal/api/handlers/plugins.go index 47c749f1..ed53be34 100644 --- a/internal/api/handlers/plugins.go +++ b/internal/api/handlers/plugins.go @@ -313,7 +313,7 @@ type pluginChunkedUploadSessionResponse struct { func (h *PluginHandler) HandleListRepositories(w http.ResponseWriter, r *http.Request) { repositories, err := h.repositories.List(r.Context()) if err != nil { - slog.Error("listing plugin repositories", "error", err) + slog.ErrorContext(r.Context(), "listing plugin repositories", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin repositories") return } @@ -342,7 +342,7 @@ func (h *PluginHandler) HandleCreateRepository(w http.ResponseWriter, r *http.Re Enabled: req.Enabled, }) if err != nil { - slog.Error("creating plugin repository", "error", err) + slog.ErrorContext(r.Context(), "creating plugin repository", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create plugin repository") return } @@ -378,14 +378,14 @@ func (h *PluginHandler) HandleUpdateRepository(w http.ResponseWriter, r *http.Re writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found") return } - slog.Error("updating plugin repository", "error", err) + slog.ErrorContext(r.Context(), "updating plugin repository", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin repository") return } repository, err := h.repositories.GetByID(r.Context(), id) if err != nil { - slog.Error("loading updated plugin repository", "error", err) + slog.ErrorContext(r.Context(), "loading updated plugin repository", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin repository") return } @@ -405,7 +405,7 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re writeError(w, http.StatusNotFound, "not_found", "Plugin repository not found") return } - slog.Error("deleting plugin repository", "error", err) + slog.ErrorContext(r.Context(), "deleting plugin repository", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin repository") return } @@ -416,7 +416,7 @@ func (h *PluginHandler) HandleDeleteRepository(w http.ResponseWriter, r *http.Re func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { entries, err := h.service.FetchCatalog(r.Context()) if err != nil { - slog.Error("fetching plugin catalog", "error", err) + slog.ErrorContext(r.Context(), "fetching plugin catalog", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch plugin catalog") return } @@ -443,14 +443,14 @@ func (h *PluginHandler) HandleCatalog(w http.ResponseWriter, r *http.Request) { func (h *PluginHandler) HandleListInstallations(w http.ResponseWriter, r *http.Request) { installations, err := h.installations.List(r.Context()) if err != nil { - slog.Error("listing plugin installations", "error", err) + slog.ErrorContext(r.Context(), "listing plugin installations", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin installations") return } response, err := h.buildInstallationResponses(r.Context(), installations) if err != nil { - slog.Error("building plugin installations response", "error", err) + slog.ErrorContext(r.Context(), "building plugin installations response", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") return } @@ -497,7 +497,7 @@ func (h *PluginHandler) HandleCreateInstallation(w http.ResponseWriter, r *http. }) } if err != nil { - slog.Error("installing plugin archive", "error", err) + slog.ErrorContext(r.Context(), "installing plugin archive", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install plugin") return } @@ -506,7 +506,7 @@ func (h *PluginHandler) HandleCreateInstallation(w http.ResponseWriter, r *http. response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest) if err != nil { - slog.Error("building installed plugin response", "error", err) + slog.ErrorContext(r.Context(), "building installed plugin response", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") return } @@ -530,7 +530,7 @@ func (h *PluginHandler) HandleUploadInstallation(w http.ResponseWriter, r *http. tempFile, err := os.CreateTemp("", "silo-plugin-*.zip") if err != nil { - slog.Error("creating temp plugin upload file", "error", err) + slog.ErrorContext(r.Context(), "creating temp plugin upload file", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload") return } @@ -541,20 +541,20 @@ func (h *PluginHandler) HandleUploadInstallation(w http.ResponseWriter, r *http. }() if _, err := io.Copy(tempFile, file); err != nil { - slog.Error("writing temp plugin upload file", "filename", header.Filename, "error", err) + slog.ErrorContext(r.Context(), "writing temp plugin upload file", "component", "api", "filename", header.Filename, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload") return } if err := tempFile.Close(); err != nil { - slog.Error("closing temp plugin upload file", "filename", header.Filename, "error", err) + slog.ErrorContext(r.Context(), "closing temp plugin upload file", "component", "api", "filename", header.Filename, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process plugin upload") return } result, err := h.installUploadedPlugin(r.Context(), tempPath) if err != nil { - slog.Error("installing uploaded plugin", "filename", header.Filename, "error", err) + slog.ErrorContext(r.Context(), "installing uploaded plugin", "component", "api", "filename", header.Filename, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to install uploaded plugin") return } @@ -619,7 +619,7 @@ func (h *PluginHandler) HandleCompleteChunkedUpload(w http.ResponseWriter, r *ht result, err := h.installUploadedPlugin(r.Context(), upload.Path) if err != nil { - slog.Error("installing chunked plugin upload", + slog.ErrorContext(r.Context(), "installing chunked plugin upload", "component", "api", "filename", upload.Filename, "upload_id", upload.ID, "error", err, @@ -664,7 +664,7 @@ func (h *PluginHandler) writeUploadedPluginResponse(w http.ResponseWriter, r *ht response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest) if err != nil { - slog.Error("building uploaded plugin response", "error", err) + slog.ErrorContext(r.Context(), "building uploaded plugin response", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") return } @@ -681,7 +681,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation caps, err := h.installations.ListCapabilities(ctx, installation.ID) if err != nil { - slog.Error("listing capabilities for metadata provider sync", + slog.ErrorContext(ctx, "listing capabilities for metadata provider sync", "component", "api", "installation_id", installation.ID, "error", err) return } @@ -693,7 +693,7 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation if err := h.chainRepo.AppendProviderToAllChains(ctx, installation.ID, cap.ID, func(level string) metadata.SeedPlacement { return metadata.LookupSeedPlacement(ctx, h.chainRepo.Pool(), installation.ID, cap.ID, level) }); err != nil { - slog.Warn("failed to append provider to library chains", + slog.WarnContext(ctx, "failed to append provider to library chains", "component", "api", "installation_id", installation.ID, "capability_id", cap.ID, "error", err) @@ -779,14 +779,14 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http. writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") return } - slog.Error("loading current plugin installation", "error", err) + slog.ErrorContext(r.Context(), "loading current plugin installation", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation") return } if req.Enabled != nil && !*req.Enabled && currentInstallation.Enabled && h.service != nil { if err := h.service.Stop(id); err != nil && !errors.Is(err, pluginhost.ErrClientNotFound) { - slog.Error("stopping plugin before disable", "installation_id", id, "error", err) + slog.ErrorContext(r.Context(), "stopping plugin before disable", "component", "api", "installation_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to disable plugin installation") return } @@ -800,7 +800,7 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http. writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") return } - slog.Error("updating plugin installation", "error", err) + slog.ErrorContext(r.Context(), "updating plugin installation", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin installation") return } @@ -813,14 +813,14 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http. installation, err := h.installations.GetByID(r.Context(), id) if err != nil { - slog.Error("loading updated plugin installation", "error", err) + slog.ErrorContext(r.Context(), "loading updated plugin installation", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation") return } response, err := h.buildInstallationResponse(r.Context(), installation, nil) if err != nil { - slog.Error("building updated plugin installation response", "error", err) + slog.ErrorContext(r.Context(), "building updated plugin installation response", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build plugin installation response") return } @@ -841,7 +841,7 @@ func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") return } - slog.Error("apply plugin update", "installation_id", id, "error", err) + slog.ErrorContext(r.Context(), "apply plugin update", "component", "api", "installation_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update plugin") return } @@ -850,7 +850,7 @@ func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request response, err := h.buildInstallationResponse(r.Context(), installation, nil) if err != nil { - slog.Error("building updated plugin installation response", "error", err) + slog.ErrorContext(r.Context(), "building updated plugin installation response", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build response") return } @@ -884,7 +884,7 @@ func (h *PluginHandler) HandlePutInstallationConfig(w http.ResponseWriter, r *ht case errors.Is(err, plugins.ErrInstallationNotFound): writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") default: - slog.Error("setting plugin global config", "installation_id", id, "error", err) + slog.ErrorContext(r.Context(), "setting plugin global config", "component", "api", "installation_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save plugin config") } return @@ -929,7 +929,7 @@ func (h *PluginHandler) HandleTestInstallationConfig(w http.ResponseWriter, r *h return } - slog.Error("testing plugin config", "installation_id", id, "error", err) + slog.ErrorContext(r.Context(), "testing plugin config", "component", "api", "installation_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to test plugin config") return } @@ -965,7 +965,7 @@ func (h *PluginHandler) HandlePutAuthBinding(w http.ResponseWriter, r *http.Requ AutoProvision: req.AutoProvision, DefaultLogin: req.DefaultLogin, }); err != nil { - slog.Error("saving plugin auth binding", "error", err) + slog.ErrorContext(r.Context(), "saving plugin auth binding", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save auth binding") return } @@ -998,7 +998,7 @@ func (h *PluginHandler) HandlePutTaskBinding(w http.ResponseWriter, r *http.Requ Enabled: req.Enabled, Trigger: req.Trigger, }); err != nil { - slog.Error("saving plugin task binding", "error", err) + slog.ErrorContext(r.Context(), "saving plugin task binding", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save task binding") return } @@ -1019,7 +1019,7 @@ func (h *PluginHandler) HandleDeleteInstallation(w http.ResponseWriter, r *http. writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") return } - slog.Error("deleting plugin installation", "error", err) + slog.ErrorContext(r.Context(), "deleting plugin installation", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete plugin installation") return } @@ -1048,7 +1048,7 @@ func manifestHasUserNavigableRoute(manifest *pluginv1.PluginManifest) bool { func (h *PluginHandler) HandleListUserPluginSettings(w http.ResponseWriter, r *http.Request) { installations, err := h.installations.ListEnabled(r.Context()) if err != nil { - slog.Error("listing enabled plugin installations", "error", err) + slog.ErrorContext(r.Context(), "listing enabled plugin installations", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list plugin settings") return } @@ -1059,7 +1059,7 @@ func (h *PluginHandler) HandleListUserPluginSettings(w http.ResponseWriter, r *h for _, installation := range installations { manifest, err := plugins.LoadManifestFile(plugins.InstalledManifestPath(installation.InstallPath)) if err != nil { - slog.Error("loading plugin manifest", "installation_id", installation.ID, "error", err) + slog.ErrorContext(r.Context(), "loading plugin manifest", "component", "api", "installation_id", installation.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings") return } @@ -1087,7 +1087,7 @@ func (h *PluginHandler) HandleGetUserPluginSettings(w http.ResponseWriter, r *ht userID := apimw.GetUserID(r.Context()) values, err := h.userConfig.Get(r.Context(), userID, id) if err != nil { - slog.Error("loading plugin user config", "installation_id", id, "error", err) + slog.ErrorContext(r.Context(), "loading plugin user config", "component", "api", "installation_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings") return } @@ -1135,7 +1135,7 @@ func (h *PluginHandler) loadUserConfigInstallation( writeError(w, http.StatusNotFound, "not_found", "Plugin installation not found") return nil, nil, err } - slog.Error("loading plugin installation", "installation_id", installationID, "error", err) + slog.ErrorContext(r.Context(), "loading plugin installation", "component", "api", "installation_id", installationID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin installation") return nil, nil, err } @@ -1146,7 +1146,7 @@ func (h *PluginHandler) loadUserConfigInstallation( manifest, err := h.loadInstallationManifest(r.Context(), installation) if err != nil { - slog.Error("loading plugin manifest", "installation_id", installationID, "error", err) + slog.ErrorContext(r.Context(), "loading plugin manifest", "component", "api", "installation_id", installationID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load plugin settings") return nil, nil, err } diff --git a/internal/api/handlers/policy.go b/internal/api/handlers/policy.go index c059c8b0..cabe6099 100644 --- a/internal/api/handlers/policy.go +++ b/internal/api/handlers/policy.go @@ -678,7 +678,7 @@ func (h *PolicyHandler) notifyChanged(r *http.Request) policyApplyStatus { } status := h.system.ApplyChanged(r.Context()) if err := status.Err(); err != nil { - slog.Error("policy notify changed failed", "error", err, "failed_step", status.FailedStep()) + slog.ErrorContext(r.Context(), "policy notify changed failed", "component", "api", "error", err, "failed_step", status.FailedStep()) } return policyApplyStatus{ Applied: status.Applied(), diff --git a/internal/api/handlers/policy_test.go b/internal/api/handlers/policy_test.go index 4d4aa506..b9e50f0b 100644 --- a/internal/api/handlers/policy_test.go +++ b/internal/api/handlers/policy_test.go @@ -458,7 +458,7 @@ func newStartedPolicyHandlerSystem(t *testing.T, ctx context.Context, store *pol system := policy.NewSystem( store, bus, - slog.New(slog.NewTextHandler(io.Discard, nil)), + slog.New(slog.DiscardHandler), policy.WithSystemPollInterval(time.Hour), ) if err := system.Start(ctx); err != nil { @@ -611,7 +611,7 @@ func TestPolicyActivateReportsLocalReloadFailureDB(t *testing.T) { system := policy.NewSystem( policy.NewPolicyStore(badPool), &cache.NoopEventBus{}, - slog.New(slog.NewTextHandler(io.Discard, nil)), + slog.New(slog.DiscardHandler), policy.WithSystemPollInterval(time.Hour), ) if err := system.Start(ctx); err != nil { diff --git a/internal/api/handlers/profile_refresh.go b/internal/api/handlers/profile_refresh.go index 7e9809d7..1c71c406 100644 --- a/internal/api/handlers/profile_refresh.go +++ b/internal/api/handlers/profile_refresh.go @@ -18,7 +18,7 @@ type ProfileRefreshRequester interface { func triggerProfileRefresh(ctx context.Context, staler ProfileStaler, requester ProfileRefreshRequester, userID int, profileID string) { if staler != nil { if err := staler.MarkProfileStale(ctx, userID, profileID); err != nil { - slog.Warn("failed to mark profile stale", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "failed to mark profile stale", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) } } if requester != nil { diff --git a/internal/api/handlers/profiles.go b/internal/api/handlers/profiles.go index a5e80e90..7b166fbd 100644 --- a/internal/api/handlers/profiles.go +++ b/internal/api/handlers/profiles.go @@ -593,7 +593,7 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ } if currentProfile.Avatar != "" && avatarRef != nil && avatarRefReplacesUpload(currentProfile.Avatar, *avatarRef) { if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil { - slog.Warn("profile avatar cleanup failed after update", "user_id", userID, "profile_id", profileID, "error", cleanupErr) + slog.WarnContext(r.Context(), "profile avatar cleanup failed after update", "component", "api", "user_id", userID, "profile_id", profileID, "error", cleanupErr) } } @@ -656,14 +656,14 @@ func (h *ProfileHandler) HandleDeleteProfile(w http.ResponseWriter, r *http.Requ } if isUploadedAvatarRef(profile.Avatar) { if cleanupErr := deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID); cleanupErr != nil { - slog.Warn("profile avatar cleanup failed after delete", "user_id", userID, "profile_id", profileID, "error", cleanupErr) + slog.WarnContext(r.Context(), "profile avatar cleanup failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", cleanupErr) } } if h.DeviceLibraryPurger != nil { purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Second) defer cancel() if purgeErr := h.DeviceLibraryPurger.PurgeProfileDevices(purgeCtx, userID, profileID); purgeErr != nil { - slog.Warn("profile device-library purge failed after delete", "user_id", userID, "profile_id", profileID, "error", purgeErr) + slog.WarnContext(r.Context(), "profile device-library purge failed after delete", "component", "api", "user_id", userID, "profile_id", profileID, "error", purgeErr) } } @@ -806,7 +806,7 @@ func (h *ProfileHandler) HandleListHouseholdSessions(w http.ResponseWriter, r *h sessions, err := h.SessionsReader.Load(r.Context(), r, PlaybackSessionsQuery{UserID: userID}) if err != nil { - slog.Error("failed to list household playback sessions", "user_id", userID, "error", err) + slog.ErrorContext(r.Context(), "failed to list household playback sessions", "component", "api", "user_id", userID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list playback sessions") return } diff --git a/internal/api/handlers/progress.go b/internal/api/handlers/progress.go index 30cf1642..d2a1e23c 100644 --- a/internal/api/handlers/progress.go +++ b/internal/api/handlers/progress.go @@ -342,7 +342,7 @@ func (h *ProgressHandler) HandleSyncProgress(w http.ResponseWriter, r *http.Requ now := time.Now() eventAt := clampEventAt(client, now) if !client.IsZero() && client.After(now.Add(progressClockSkew)) { - slog.Warn("clamped future-dated progress event time", + slog.WarnContext(r.Context(), "clamped future-dated progress event time", "component", "api", "profile_id", profileID, "media_item_id", item.MediaItemID) } pos, completed, skip := userstore.ResolveProgressState(item.Position, item.Duration, thresholds) diff --git a/internal/api/handlers/recommendations.go b/internal/api/handlers/recommendations.go index 13cf808a..e2505372 100644 --- a/internal/api/handlers/recommendations.go +++ b/internal/api/handlers/recommendations.go @@ -140,7 +140,7 @@ func (h *RecommendationsHandler) HandleForYouMain(w http.ResponseWriter, r *http row, err := h.reader.GetForYouMain(r.Context(), userID, profileID, limit, filter) if err != nil { - slog.Error("ForYouMain failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.ErrorContext(r.Context(), "ForYouMain failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") return } @@ -166,7 +166,7 @@ func (h *RecommendationsHandler) HandleForYouRows(w http.ResponseWriter, r *http rows, err := h.reader.GetForYouRows(r.Context(), userID, profileID, limit, filter) if err != nil { - slog.Error("ForYouRows failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.ErrorContext(r.Context(), "ForYouRows failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") return } @@ -457,7 +457,7 @@ func (h *RecommendationsHandler) HandleDiscover(w http.ResponseWriter, r *http.R rows, err := h.reader.GetDiscoverRows(r.Context(), userID, profileID, 20, filter) if err != nil { - slog.Error("Discover failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.ErrorContext(r.Context(), "Discover failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") return } @@ -473,8 +473,8 @@ func (h *RecommendationsHandler) HandleDiscover(w http.ResponseWriter, r *http.R rows, ) if upcomingErr != nil { - slog.Warn( - "Discover: schedule-aware blending unavailable; falling back to legacy rows", + slog.WarnContext(r.Context(), + "Discover: schedule-aware blending unavailable; falling back to legacy rows", "component", "api", "user_id", userID, "profile_id", @@ -558,8 +558,8 @@ func (h *RecommendationsHandler) HandleSection(w http.ResponseWriter, r *http.Re row, err := h.reader.GetSection(r.Context(), userID, profileID, kind, key, limit, filter) if err != nil { - slog.Error( - "Section failed", + slog.ErrorContext(r.Context(), + "Section failed", "component", "api", "user_id", userID, "profile_id", profileID, "kind", kind, @@ -627,7 +627,7 @@ func (h *RecommendationsHandler) loadItemEnrichment( mediaItems, err := h.Fetcher.FetchItemsByContentIDs(ctx, ids, filter) if err != nil { - slog.Error("Recommendations: fetch items failed", "error", err) + slog.ErrorContext(ctx, "Recommendations: fetch items failed", "component", "api", "error", err) return nil, err } for _, mi := range mediaItems { @@ -636,7 +636,7 @@ func (h *RecommendationsHandler) loadItemEnrichment( overlays, overlayErr := h.Fetcher.ListOverlaySummaries(ctx, ids, filter) if overlayErr != nil { - slog.Error("Recommendations: overlay summaries failed", "error", overlayErr) + slog.ErrorContext(ctx, "Recommendations: overlay summaries failed", "component", "api", "error", overlayErr) } else { out.overlays = overlays } diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index e2a19189..2201ab85 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -388,7 +388,7 @@ func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context. collection, err := h.CollectionRepo.GetByID(ctx, collectionID) if err != nil { if !errors.Is(err, catalog.ErrLibraryCollectionNotFound) { - slog.Warn("failed to load section-managed collection during section delete", "collection_id", collectionID, "error", err) + slog.WarnContext(ctx, "failed to load section-managed collection during section delete", "component", "api", "collection_id", collectionID, "error", err) } return } @@ -397,14 +397,14 @@ func (h *SectionHandler) deleteUnreferencedSectionManagedCollection(ctx context. } refs, err := h.repo.CountLibraryCollectionReferences(ctx, collectionID, "") if err != nil { - slog.Warn("failed to count section-managed collection references", "collection_id", collectionID, "error", err) + slog.WarnContext(ctx, "failed to count section-managed collection references", "component", "api", "collection_id", collectionID, "error", err) return } if refs > 0 { return } if err := h.CollectionRepo.Delete(ctx, collectionID); err != nil && !errors.Is(err, catalog.ErrLibraryCollectionNotFound) { - slog.Warn("failed to delete unreferenced section-managed collection", "collection_id", collectionID, "error", err) + slog.WarnContext(ctx, "failed to delete unreferenced section-managed collection", "component", "api", "collection_id", collectionID, "error", err) } } @@ -608,7 +608,7 @@ func (h *SectionHandler) HandleHomeSectionItems(w http.ResponseWriter, r *http.R withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, nil, libraryIDs, userID, profileID, accessFilter) if fetchErr != nil { - slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) + slog.ErrorContext(r.Context(), "fetching section items", "component", "api", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) withItems = sections.SectionWithItems{ ResolvedSection: s, Items: []*models.MediaItem{}, @@ -690,7 +690,7 @@ func (h *SectionHandler) HandleLibrarySectionItems(w http.ResponseWriter, r *htt withItems, fetchErr := h.fetcher.FetchOne(r.Context(), s, &libraryID, nil, userID, profileID, accessFilter) if fetchErr != nil { - slog.Error("fetching section items", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) + slog.ErrorContext(r.Context(), "fetching section items", "component", "api", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) withItems = sections.SectionWithItems{ ResolvedSection: s, Items: []*models.MediaItem{}, @@ -782,7 +782,7 @@ func (h *SectionHandler) loadResolvedLibrarySections(r *http.Request, libraryID if len(adminSections) == 0 { defaults, defaultsErr := h.defaultLibrarySections(r.Context(), libraryID) if defaultsErr != nil { - slog.Warn("loading typed library section defaults", "library_id", libraryID, "error", defaultsErr) + slog.WarnContext(r.Context(), "loading typed library section defaults", "component", "api", "library_id", libraryID, "error", defaultsErr) adminSections = sections.DefaultLibrarySections(&libraryID) } else { adminSections = defaults @@ -1224,7 +1224,7 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect if len(contentIDs) > 0 && h.fetcher != nil { summaries, err := h.fetcher.ListOverlaySummaries(r.Context(), contentIDs, requestAccessFilter(r)) if err != nil { - slog.Error("loading overlay summaries", "error", err) + slog.ErrorContext(r.Context(), "loading overlay summaries", "component", "api", "error", err) } else { overlaySummaries = summaries } @@ -1305,7 +1305,7 @@ func (h *SectionHandler) listSectionMangaChapterItemMeta(ctx context.Context, it } meta, err := h.fetcher.FetchMangaChapterSeriesMeta(ctx, ids) if err != nil { - slog.Warn("loading section manga chapter metadata", "error", err) + slog.WarnContext(ctx, "loading section manga chapter metadata", "component", "api", "error", err) return map[string]sections.SectionItemMeta{} } return meta @@ -1341,7 +1341,7 @@ func (h *SectionHandler) listSectionEpisodeItemMeta(ctx context.Context, withIte _, meta, err := h.episodeFetcher.FetchEpisodesByContentIDs(ctx, ids, filter) if err != nil { - slog.Warn("loading section episode metadata", "error", err) + slog.WarnContext(ctx, "loading section episode metadata", "component", "api", "error", err) return map[string]sections.SectionItemMeta{} } return meta @@ -1622,7 +1622,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re if req.Scope == "home" { defaults, err = h.defaultHomeSections(r.Context()) if err != nil { - slog.Error("loading default home sections", "error", err) + slog.ErrorContext(r.Context(), "loading default home sections", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load libraries") return } @@ -1633,7 +1633,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re writeError(w, http.StatusNotFound, "not_found", "Library not found") return } - slog.Error("loading default library sections", "library_id", *req.LibraryID, "error", err) + slog.ErrorContext(r.Context(), "loading default library sections", "component", "api", "library_id", *req.LibraryID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library") return } @@ -1641,7 +1641,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re created, err := h.repo.RestoreDefaults(r.Context(), req.Scope, req.LibraryID, defaults) if err != nil { - slog.Error("restoring default sections", "scope", req.Scope, "error", err) + slog.ErrorContext(r.Context(), "restoring default sections", "component", "api", "scope", req.Scope, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to restore defaults") return } @@ -1653,7 +1653,7 @@ func (h *SectionHandler) HandleRestoreDefaults(w http.ResponseWriter, r *http.Re libraryIDStr = strconv.Itoa(*req.LibraryID) } if err := h.repo.ClearAllProfileOverrides(r.Context(), req.Scope, libraryIDStr); err != nil { - slog.Error("clearing profile overrides", "scope", req.Scope, "error", err) + slog.ErrorContext(r.Context(), "clearing profile overrides", "component", "api", "scope", req.Scope, "error", err) // Don't fail the whole request — sections were already restored. } } diff --git a/internal/api/handlers/server_control.go b/internal/api/handlers/server_control.go index c6436a9f..bf321b4b 100644 --- a/internal/api/handlers/server_control.go +++ b/internal/api/handlers/server_control.go @@ -70,7 +70,7 @@ func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Requ }) return } - slog.Error("server restart request failed", "error", err) + slog.ErrorContext(r.Context(), "server restart request failed", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to request server restart") return } diff --git a/internal/api/handlers/session_ws.go b/internal/api/handlers/session_ws.go index f2836d21..0b56ad71 100644 --- a/internal/api/handlers/session_ws.go +++ b/internal/api/handlers/session_ws.go @@ -79,7 +79,7 @@ func (h *PlaybackHandler) HandleSessionWebSocket(w http.ResponseWriter, r *http. conn, err := wsUpgrader.Upgrade(w, r, nil) if err != nil { - slog.Error("websocket upgrade failed", "error", err, "session", sessionID, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "websocket upgrade failed", "component", "api", "error", err, "session", sessionID, "playback_session_id", sessionID) return } @@ -87,7 +87,7 @@ func (h *PlaybackHandler) HandleSessionWebSocket(w http.ResponseWriter, r *http. registration := h.RealtimeHub.Register(sessionID, realtimeConn) if registration == nil { conn.Close() - slog.Warn("failed to register realtime websocket", "session", sessionID, "playback_session_id", sessionID) + slog.WarnContext(r.Context(), "failed to register realtime websocket", "component", "api", "session", sessionID, "playback_session_id", sessionID) return } @@ -110,7 +110,7 @@ func (h *PlaybackHandler) HandleSessionWebSocket(w http.ResponseWriter, r *http. break } if err := h.handleRealtimeClientMessage(sessionID, data); err != nil { - slog.Warn("invalid realtime client message", "session", sessionID, "playback_session_id", sessionID, "error", err) + slog.WarnContext(r.Context(), "invalid realtime client message", "component", "api", "session", sessionID, "playback_session_id", sessionID, "error", err) } } } diff --git a/internal/api/handlers/settings.go b/internal/api/handlers/settings.go index 8c5f6c0e..4930493b 100644 --- a/internal/api/handlers/settings.go +++ b/internal/api/handlers/settings.go @@ -685,7 +685,7 @@ func (h *SettingsHandler) registerRequestDevice( DeviceName: device.DeviceName, DevicePlatform: device.DevicePlatform, }); err != nil { - slog.Warn("failed to register request device", + slog.WarnContext(ctx, "failed to register request device", "component", "api", "profile_id", profileID, "device_id", device.DeviceID, "error", err, diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index b8b9a9f2..536454e9 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -266,7 +266,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { // surface it as an internal error (with a server-side signal) // so the real failure is diagnosable instead of looking like an // intermittent 404 to the client. - slog.ErrorContext(r.Context(), "list downloaded subtitles failed", + slog.ErrorContext(r.Context(), "list downloaded subtitles failed", "component", "api", "file_id", file.ID, "track", trackIndex, "error", err, @@ -373,7 +373,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.ffmpegPath()) if err != nil { - slog.WarnContext(r.Context(), "subtitle font extraction failed", + slog.WarnContext(r.Context(), "subtitle font extraction failed", "component", "api", "file_id", file.ID, "track", trackIndex, "error", err, @@ -386,7 +386,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Cache-Control", "no-store") if err := json.NewEncoder(w).Encode(playback.EncodeSubtitleFontBundle(fonts)); err != nil { - slog.WarnContext(r.Context(), "subtitle font response encode failed", "error", err) + slog.WarnContext(r.Context(), "subtitle font response encode failed", "component", "api", "error", err) } } @@ -395,7 +395,7 @@ func (h *StreamHandler) syncSessionsNow(ctx context.Context, reason string) { return } if err := h.SessionSyncer.SyncNow(ctx); err != nil { - slog.Error("failed to sync sessions", "reason", reason, "error", err) + slog.ErrorContext(ctx, "failed to sync sessions", "component", "api", "reason", reason, "error", err) } } @@ -409,7 +409,7 @@ func (h *StreamHandler) finalizeSessionAbort(ctx context.Context, session *playb if h.AdminStore != nil { if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil { - slog.Error("failed to delete synced session", "session", session.ID, "error", err) + slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err) } } if syncNow { @@ -438,7 +438,7 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session h.abortPlaybackSession(ctx, session) return } - slog.Warn("stream transport startup failed", + slog.WarnContext(ctx, "stream transport startup failed", "component", "api", "session", session.ID, "file_id", session.MediaFileID, "error", err, @@ -471,7 +471,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re seek = subtitleSeekPosition(r, session) duration = subtitleWindowDuration(r) } - slog.InfoContext(r.Context(), "subtitle stream requested", + slog.InfoContext(r.Context(), "subtitle stream requested", "component", "api", "file_id", file.ID, "embedded_index", embeddedIndex, "track_language", track.Language, diff --git a/internal/api/handlers/subtitle_ai.go b/internal/api/handlers/subtitle_ai.go index 67dd1a1c..fd9315b1 100644 --- a/internal/api/handlers/subtitle_ai.go +++ b/internal/api/handlers/subtitle_ai.go @@ -136,7 +136,7 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque case errors.Is(err, ai.ErrInvalidRequest): writeError(w, http.StatusBadRequest, "bad_request", err.Error()) default: - slog.Error("failed to enqueue subtitle translation", + slog.ErrorContext(r.Context(), "failed to enqueue subtitle translation", "component", "api", "media_file_id", req.MediaFileID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation") } @@ -193,7 +193,7 @@ func quotaExceededMessage(err error) string { func (h *SubtitleAIHandler) HandleQuota(w http.ResponseWriter, r *http.Request) { quota, err := h.service.TranscribeQuota(r.Context(), apimw.GetUserID(r.Context()), h.quotaExempt(r)) if err != nil { - slog.Error("failed to load transcription quota", "error", err) + slog.ErrorContext(r.Context(), "failed to load transcription quota", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load transcription quota") return } diff --git a/internal/api/handlers/subtitle_search.go b/internal/api/handlers/subtitle_search.go index 430ffcfb..5b9a1137 100644 --- a/internal/api/handlers/subtitle_search.go +++ b/internal/api/handlers/subtitle_search.go @@ -173,7 +173,7 @@ func (h *SubtitleSearchHandler) HandleDownload(w http.ResponseWriter, r *http.Re HearingImpaired: req.HearingImpaired, }) if err != nil { - slog.Error("subtitle download failed", "provider", req.Provider, "subtitle_id", req.SubtitleID, "error", err) + slog.ErrorContext(r.Context(), "subtitle download failed", "component", "api", "provider", req.Provider, "subtitle_id", req.SubtitleID, "error", err) writeError(w, http.StatusInternalServerError, "download_error", "Failed to download subtitle") return } @@ -242,7 +242,7 @@ func (h *SubtitleSearchHandler) HandleUpload(w http.ResponseWriter, r *http.Requ case strings.Contains(err.Error(), "exceeds maximum size"): writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Subtitle file must be under 5 MB") default: - slog.Error("subtitle upload failed", "media_file_id", mediaFileID, "error", err) + slog.ErrorContext(r.Context(), "subtitle upload failed", "component", "api", "media_file_id", mediaFileID, "error", err) writeError(w, http.StatusInternalServerError, "upload_error", "Failed to upload subtitle") } return diff --git a/internal/api/handlers/system.go b/internal/api/handlers/system.go index 62b212f7..175d8e8d 100644 --- a/internal/api/handlers/system.go +++ b/internal/api/handlers/system.go @@ -42,7 +42,7 @@ func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, info) return } - slog.Warn("hw-accel: remote node probe failed, falling back to local", + slog.WarnContext(r.Context(), "hw-accel: remote node probe failed, falling back to local", "component", "api", "node", node.URL, "error", err) } } diff --git a/internal/api/handlers/taste_seed.go b/internal/api/handlers/taste_seed.go index 21f863f9..049eb48e 100644 --- a/internal/api/handlers/taste_seed.go +++ b/internal/api/handlers/taste_seed.go @@ -53,7 +53,7 @@ func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r * // Fetch a page-sized window of candidate IDs ordered by engagement + rating. candidateIDs, err := h.recsRepo.GetTasteSeedCandidates(r.Context(), limit, offset) if err != nil { - slog.Error("TasteSeedItems: candidate query failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.ErrorContext(r.Context(), "TasteSeedItems: candidate query failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch taste seed candidates") return } @@ -66,7 +66,7 @@ func (h *RecommendationsHandler) HandleTasteSeedItems(w http.ResponseWriter, r * // Hydrate items, applying the user's library/content-rating access filter. mediaItems, err := h.Fetcher.FetchItemsByContentIDs(r.Context(), candidateIDs, filter) if err != nil { - slog.Error("TasteSeedItems: hydrate failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.ErrorContext(r.Context(), "TasteSeedItems: hydrate failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to hydrate taste seed items") return } @@ -136,7 +136,7 @@ func (h *RecommendationsHandler) HandleTasteSeed(w http.ResponseWriter, r *http. store, err := h.storeProvider.ForUser(r.Context(), userID) if err != nil || store == nil { - slog.Error("TasteSeed: failed to load user store", "user_id", userID, "error", err) + slog.ErrorContext(r.Context(), "TasteSeed: failed to load user store", "component", "api", "user_id", userID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user store") return } @@ -149,7 +149,7 @@ func (h *RecommendationsHandler) HandleTasteSeed(w http.ResponseWriter, r *http. if err := store.AddFavorite(r.Context(), profileID, id); err != nil { // Don't fail the whole request on a single item error — log and // continue so the user's other picks still seed their profile. - slog.Warn("TasteSeed: failed to add favorite", "user_id", userID, "profile_id", profileID, "item_id", id, "error", err) + slog.WarnContext(r.Context(), "TasteSeed: failed to add favorite", "component", "api", "user_id", userID, "profile_id", profileID, "item_id", id, "error", err) continue } added++ diff --git a/internal/api/handlers/user_collection_imports.go b/internal/api/handlers/user_collection_imports.go index f5bb336c..50471bf5 100644 --- a/internal/api/handlers/user_collection_imports.go +++ b/internal/api/handlers/user_collection_imports.go @@ -284,7 +284,7 @@ func (h *UserCollectionImportHandler) storeBundledTemplatePoster( ) if err != nil || !stored { if err != nil { - slog.Warn("failed to store bundled user collection poster", + slog.WarnContext(r.Context(), "failed to store bundled user collection poster", "component", "api", "collection_id", collection.ID, "poster_path", posterPath, "error", err, @@ -299,7 +299,7 @@ func (h *UserCollectionImportHandler) storeBundledTemplatePoster( PosterURL: &storedPath, PosterThumbhash: &thumbhash, }); err != nil { - slog.Warn("failed to persist bundled user collection poster", + slog.WarnContext(r.Context(), "failed to persist bundled user collection poster", "component", "api", "collection_id", collection.ID, "poster_path", posterPath, "stored_path", storedPath, diff --git a/internal/api/handlers/watch_tonight.go b/internal/api/handlers/watch_tonight.go index 10c7e997..79ea94f3 100644 --- a/internal/api/handlers/watch_tonight.go +++ b/internal/api/handlers/watch_tonight.go @@ -83,12 +83,12 @@ func (h *RecommendationsHandler) HandleWatchTonight(w http.ResponseWriter, r *ht wg.Wait() if recErr != nil { - slog.Error("WatchTonight: recommendations fetch failed", "user_id", userID, "error", recErr) + slog.ErrorContext(r.Context(), "WatchTonight: recommendations fetch failed", "component", "api", "user_id", userID, "error", recErr) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch recommendations") return } if liveErr != nil { - slog.Warn("WatchTonight: live CW/NextUp fetch failed, continuing with recs only", "user_id", userID, "error", liveErr) + slog.WarnContext(r.Context(), "WatchTonight: live CW/NextUp fetch failed, continuing with recs only", "component", "api", "user_id", userID, "error", liveErr) } // Filter out already-watched and low-rated items from rec candidates. @@ -295,7 +295,7 @@ func (h *RecommendationsHandler) fetchLiveCWAndNextUp(r *http.Request, userID in if h.WatchTonightFetcher != nil { nuItems, nuMeta, nuErr := h.WatchTonightFetcher.FetchNextUpItems(ctx, userID, profileID, nil, nil, filter, limit) if nuErr != nil { - slog.Warn("WatchTonight: next-up fetch failed", "user_id", userID, "error", nuErr) + slog.WarnContext(r.Context(), "WatchTonight: next-up fetch failed", "component", "api", "user_id", userID, "error", nuErr) } else { for _, item := range nuItems { meta, ok := nuMeta[item.ContentID] @@ -331,7 +331,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil // Fetch movies/series from media_items table. mediaItems, err := h.Fetcher.FetchItemsByContentIDs(ctx, contentIDs, filter) if err != nil { - slog.Error("WatchTonight: fetch items failed", "error", err) + slog.ErrorContext(r.Context(), "WatchTonight: fetch items failed", "component", "api", "error", err) return nil, nil, nil, nil } @@ -343,7 +343,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil // Also fetch episodes — CW/Next-Up content IDs are typically episode IDs. episodeItems, epMeta, epErr := h.Fetcher.FetchEpisodesByContentIDs(ctx, contentIDs, filter) if epErr != nil { - slog.Error("WatchTonight: fetch episodes failed", "error", epErr) + slog.ErrorContext(r.Context(), "WatchTonight: fetch episodes failed", "component", "api", "error", epErr) } else { for _, item := range episodeItems { itemMap[item.ContentID] = item @@ -354,7 +354,7 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil overlays, err := h.Fetcher.ListOverlaySummaries(ctx, contentIDs, filter) if err != nil { - slog.Error("WatchTonight: overlay summaries failed", "error", err) + slog.ErrorContext(r.Context(), "WatchTonight: overlay summaries failed", "component", "api", "error", err) } else { overlayMap = overlays } diff --git a/internal/api/handlers/watch_tonight_cards.go b/internal/api/handlers/watch_tonight_cards.go index 27d07b9b..0174ce68 100644 --- a/internal/api/handlers/watch_tonight_cards.go +++ b/internal/api/handlers/watch_tonight_cards.go @@ -219,7 +219,7 @@ func (h *RecommendationsHandler) HandleWatchTonightCards(w http.ResponseWriter, func (h *RecommendationsHandler) buildContinueCards(r *http.Request, userID int, profileID string, filter catalog.AccessFilter, excludeIDs map[string]struct{}, limit int) ([]mergedItem, bool) { cwItems, nextUpItems, err := h.fetchLiveCWAndNextUp(r, userID, profileID, filter, limit+len(excludeIDs)) if err != nil { - slog.Warn("WatchTonightCards: CW/NextUp fetch failed", "user_id", userID, "error", err) + slog.WarnContext(r.Context(), "WatchTonightCards: CW/NextUp fetch failed", "component", "api", "user_id", userID, "error", err) return nil, false } @@ -277,7 +277,7 @@ func (h *RecommendationsHandler) buildDiscoverCards(r *http.Request, userID int, poolSize := discoverCandidatePoolSize(limit, len(genres)) embedding, err := h.recsRepo.GetTasteProfile(ctx, userID, profileID) if err != nil { - slog.Warn("WatchTonightCards: taste profile fetch failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(r.Context(), "WatchTonightCards: taste profile fetch failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) } candidates := []recommendations.ScoredItem{} @@ -294,7 +294,7 @@ func (h *RecommendationsHandler) buildDiscoverCards(r *http.Request, userID int, filter, ) if err != nil { - slog.Warn("WatchTonightCards: taste candidate query failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(r.Context(), "WatchTonightCards: taste candidate query failed", "component", "api", "user_id", userID, "profile_id", profileID, "error", err) } else { candidates = candidateItems genreMap = candidateGenres @@ -344,7 +344,7 @@ func (h *RecommendationsHandler) buildColdStartDiscoverCandidates(ctx context.Co "", ) if err != nil { - slog.Warn("WatchTonightCards: popular cache unavailable", "error", err) + slog.WarnContext(ctx, "WatchTonightCards: popular cache unavailable", "component", "api", "error", err) } for _, item := range popular { byID[item.MediaItemID] = item @@ -358,7 +358,7 @@ func (h *RecommendationsHandler) buildColdStartDiscoverCandidates(ctx context.Co "", ) if err != nil { - slog.Warn("WatchTonightCards: recently added cache unavailable", "error", err) + slog.WarnContext(ctx, "WatchTonightCards: recently added cache unavailable", "component", "api", "error", err) } for _, item := range recentlyAdded { if existing, exists := byID[item.MediaItemID]; !exists || item.Score > existing.Score { @@ -387,7 +387,7 @@ func (h *RecommendationsHandler) fetchCastByIDs(ctx context.Context, lookupIDs [ } castMap, err := h.CastFetcher.ListForItems(ctx, lookupIDs) if err != nil { - slog.Error("WatchTonightCards: cast fetch failed", "error", err) + slog.ErrorContext(ctx, "WatchTonightCards: cast fetch failed", "component", "api", "error", err) return nil } return castMap diff --git a/internal/api/handlers/webhook_sync.go b/internal/api/handlers/webhook_sync.go index c86a4d1f..4d7bc3ff 100644 --- a/internal/api/handlers/webhook_sync.go +++ b/internal/api/handlers/webhook_sync.go @@ -381,7 +381,7 @@ func (h *WebhookSyncHandler) HandleWebhook(w http.ResponseWriter, r *http.Reques BodyExcerpt: logContext.BodyExcerpt, Attrs: webhookEventAttrs(logContext, result), }); logErr != nil { - slog.Warn("webhook sync: failed to persist webhook event log", "connection_id", result.ConnectionID, "error", logErr) + slog.WarnContext(r.Context(), "webhook sync: failed to persist webhook event log", "component", "api", "connection_id", result.ConnectionID, "error", logErr) } } logWebhookDelivery(result, logContext, statusCode, err) diff --git a/internal/api/middleware/request_logger.go b/internal/api/middleware/request_logger.go index 4e8fbb0d..c9407357 100644 --- a/internal/api/middleware/request_logger.go +++ b/internal/api/middleware/request_logger.go @@ -69,7 +69,7 @@ func RequestLogger(nodeID string) func(http.Handler) http.Handler { if playbackLC.PlaybackSessionID != "" { attrs = append(attrs, "playback_session_id", playbackLC.PlaybackSessionID) } - slog.Info("api request", attrs...) + slog.InfoContext(r.Context(), "api request", append([]any{"component", "api"}, attrs...)...) }) } } diff --git a/internal/audiobooks/abs/access_log.go b/internal/audiobooks/abs/access_log.go index 7cdbbfff..8416732f 100644 --- a/internal/audiobooks/abs/access_log.go +++ b/internal/audiobooks/abs/access_log.go @@ -72,10 +72,10 @@ func (h *Handler) accessLog(next http.Handler) http.Handler { args = append(args, "ua", ua) } if sw.status >= 400 { - slog.Warn("abs req failed", args...) + slog.WarnContext(r.Context(), "abs req failed", append([]any{"component", "audiobooks"}, args...)...) return } - slog.Debug("abs req", append(args, "bytes", sw.bytes)...) + slog.DebugContext(r.Context(), "abs req", append([]any{"component", "audiobooks"}, append(args, "bytes", sw.bytes)...)...) }) } diff --git a/internal/audiobooks/abs/author_series_handler.go b/internal/audiobooks/abs/author_series_handler.go index 939a502c..9b78fb21 100644 --- a/internal/audiobooks/abs/author_series_handler.go +++ b/internal/audiobooks/abs/author_series_handler.go @@ -18,7 +18,7 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") access, err := h.accessFilterForAuth(r.Context(), a) if err != nil { - slog.Warn("abs author access resolution failed", "err", err, "id", id) + slog.WarnContext(r.Context(), "abs author access resolution failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "forbidden", http.StatusForbidden) return } @@ -28,7 +28,7 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs author detail failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs author detail failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "author get failed", http.StatusInternalServerError) return } @@ -49,7 +49,7 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) { } access, err := h.accessFilterForAuth(r.Context(), a) if err != nil { - slog.Warn("abs series access resolution failed", "err", err, "id", id) + slog.WarnContext(r.Context(), "abs series access resolution failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "forbidden", http.StatusForbidden) return } @@ -59,7 +59,7 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs series detail failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs series detail failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "series get failed", http.StatusInternalServerError) return } diff --git a/internal/audiobooks/abs/bookmarks_handler.go b/internal/audiobooks/abs/bookmarks_handler.go index 7b1caebe..caf57b79 100644 --- a/internal/audiobooks/abs/bookmarks_handler.go +++ b/internal/audiobooks/abs/bookmarks_handler.go @@ -62,7 +62,7 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc { } item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access) if err != nil { - slog.Error("abs bookmark item lookup failed", "err", err, "user", a.UserID, "item", itemID) + slog.ErrorContext(r.Context(), "abs bookmark item lookup failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID) http.Error(w, "item lookup failed", http.StatusInternalServerError) return } @@ -73,7 +73,7 @@ func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc { bm, err := h.deps.BookmarkStore.Upsert(r.Context(), a.UserID, a.ProfileID, itemID, *body.Time, body.Title) if err != nil { - slog.Error("abs bookmark upsert failed", "err", err, "user", a.UserID, "item", itemID) + slog.ErrorContext(r.Context(), "abs bookmark upsert failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID) http.Error(w, "bookmark persist failed", http.StatusInternalServerError) return } @@ -132,7 +132,7 @@ func (h *Handler) handleDeleteBookmark(w http.ResponseWriter, r *http.Request) { } if err := h.deps.BookmarkStore.Delete(r.Context(), a.UserID, a.ProfileID, itemID, t); err != nil { - slog.Error("abs bookmark delete failed", "err", err, "user", a.UserID, "item", itemID) + slog.ErrorContext(r.Context(), "abs bookmark delete failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID) http.Error(w, "bookmark delete failed", http.StatusInternalServerError) return } @@ -170,7 +170,7 @@ func parseBookmarkTime(s string) (float64, bool) { func writeBookmarkList(w http.ResponseWriter, r *http.Request, h *Handler, userID, profileID, itemID string) { rows, err := h.deps.BookmarkStore.List(r.Context(), userID, profileID, itemID) if err != nil { - slog.Warn("abs bookmark list after mutation failed", "err", err, "user", userID, "item", itemID) + slog.WarnContext(r.Context(), "abs bookmark list after mutation failed", "component", "audiobooks", "err", err, "user", userID, "item", itemID) writeJSON(w, http.StatusOK, []any{}) return } diff --git a/internal/audiobooks/abs/collections_handler.go b/internal/audiobooks/abs/collections_handler.go index ba3aacb9..87de6703 100644 --- a/internal/audiobooks/abs/collections_handler.go +++ b/internal/audiobooks/abs/collections_handler.go @@ -63,7 +63,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request) c.IsPublic = *body.IsPublic } if err := h.deps.CollectionStore.CreateCollection(r.Context(), c); err != nil { - slog.Error("abs collection create failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs collection create failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "collection persist failed", http.StatusInternalServerError) return } @@ -73,7 +73,7 @@ func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request) if errors.Is(err, ErrNotFound) { persisted = c } else if err != nil { - slog.Warn("abs collection get-after-create failed", "err", err, "id", c.ID) + slog.WarnContext(r.Context(), "abs collection get-after-create failed", "component", "audiobooks", "err", err, "id", c.ID) persisted = c } writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted)) @@ -99,7 +99,7 @@ func (h *Handler) collectionBooks(r *http.Request, collectionID string) []map[st } rows, err := h.deps.CollectionStore.ListCollectionItems(r.Context(), collectionID) if err != nil { - slog.Warn("abs collection list-items failed", "err", err, "collection", collectionID) + slog.WarnContext(r.Context(), "abs collection list-items failed", "component", "audiobooks", "err", err, "collection", collectionID) return []map[string]any{} } lib := h.resolveDefaultLibrary(r.Context()) @@ -159,7 +159,7 @@ func (h *Handler) handleListLibraryCollections(w http.ResponseWriter, r *http.Re } rows, err := h.deps.CollectionStore.ListUserCollections(r.Context(), a.UserID, a.ProfileID) if err != nil { - slog.Error("abs library collection list failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs library collection list failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "collection list failed", http.StatusInternalServerError) return } @@ -200,7 +200,7 @@ func (h *Handler) handleListCollections(w http.ResponseWriter, r *http.Request) } rows, err := h.deps.CollectionStore.ListUserCollections(r.Context(), a.UserID, a.ProfileID) if err != nil { - slog.Error("abs collection list failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs collection list failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "collection list failed", http.StatusInternalServerError) return } @@ -235,7 +235,7 @@ func (h *Handler) handleGetCollection(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs collection get failed", "err", err) + slog.ErrorContext(r.Context(), "abs collection get failed", "component", "audiobooks", "err", err) http.Error(w, "collection get failed", http.StatusInternalServerError) return } @@ -262,7 +262,7 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request) return } if err != nil { - slog.Error("abs collection get-for-update failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs collection get-for-update failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "collection get failed", http.StatusInternalServerError) return } @@ -287,14 +287,14 @@ func (h *Handler) handleUpdateCollection(w http.ResponseWriter, r *http.Request) c.IsPublic = *body.IsPublic } if err := h.deps.CollectionStore.UpdateCollection(r.Context(), c); err != nil { - slog.Error("abs collection update failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs collection update failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "collection persist failed", http.StatusInternalServerError) return } persisted, err := h.deps.CollectionStore.GetCollection(r.Context(), id) if err != nil { - slog.Warn("abs collection get-after-update failed", "err", err, "id", id) + slog.WarnContext(r.Context(), "abs collection get-after-update failed", "component", "audiobooks", "err", err, "id", id) persisted = c } writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted)) @@ -327,7 +327,7 @@ func (h *Handler) handleAddCollectionBook(w http.ResponseWriter, r *http.Request return } if err != nil { - slog.Error("abs collection get-for-add failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs collection get-for-add failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "collection get failed", http.StatusInternalServerError) return } @@ -345,7 +345,7 @@ func (h *Handler) handleAddCollectionBook(w http.ResponseWriter, r *http.Request } if err := h.deps.CollectionStore.AddCollectionItem(r.Context(), id, bookID); err != nil { - slog.Error("abs collection add-item failed", "err", err, "id", id, "book", bookID) + slog.ErrorContext(r.Context(), "abs collection add-item failed", "component", "audiobooks", "err", err, "id", id, "book", bookID) http.Error(w, "collection persist failed", http.StatusInternalServerError) return } @@ -384,13 +384,13 @@ func (h *Handler) handleRemoveCollectionBook(w http.ResponseWriter, r *http.Requ return } if err != nil { - slog.Error("abs collection get-for-remove failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs collection get-for-remove failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "collection get failed", http.StatusInternalServerError) return } if err := h.deps.CollectionStore.RemoveCollectionItem(r.Context(), id, bookID); err != nil { - slog.Error("abs collection remove-item failed", "err", err, "id", id, "book", bookID) + slog.ErrorContext(r.Context(), "abs collection remove-item failed", "component", "audiobooks", "err", err, "id", id, "book", bookID) http.Error(w, "collection delete failed", http.StatusInternalServerError) return } @@ -422,12 +422,12 @@ func (h *Handler) handleDeleteCollection(w http.ResponseWriter, r *http.Request) return } if err != nil { - slog.Error("abs collection get-for-delete failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs collection get-for-delete failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "collection get failed", http.StatusInternalServerError) return } if err := h.deps.CollectionStore.DeleteCollection(r.Context(), id); err != nil { - slog.Error("abs collection delete failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs collection delete failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "collection delete failed", http.StatusInternalServerError) return } diff --git a/internal/audiobooks/abs/continue_listening_handler.go b/internal/audiobooks/abs/continue_listening_handler.go index 20cd6d35..aeac154f 100644 --- a/internal/audiobooks/abs/continue_listening_handler.go +++ b/internal/audiobooks/abs/continue_listening_handler.go @@ -33,7 +33,7 @@ func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hi } item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID, access) if err != nil { - slog.Error("abs continue item lookup failed", "err", err, "user", a.UserID, "item", itemID) + slog.ErrorContext(r.Context(), "abs continue item lookup failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID) http.Error(w, "item lookup failed", http.StatusInternalServerError) return } @@ -46,7 +46,7 @@ func (h *Handler) setHideFromContinue(w http.ResponseWriter, r *http.Request, hi return } if err := h.deps.ProgressStore.SetHideFromContinue(r.Context(), a.UserID, a.ProfileID, itemID, hide); err != nil { - slog.Error("abs continue toggle failed", "err", err, "user", a.UserID, "item", itemID, "hide", hide) + slog.ErrorContext(r.Context(), "abs continue toggle failed", "component", "audiobooks", "err", err, "user", a.UserID, "item", itemID, "hide", hide) http.Error(w, "continue toggle failed", http.StatusInternalServerError) return } diff --git a/internal/audiobooks/abs/extras_handlers.go b/internal/audiobooks/abs/extras_handlers.go index e6f0faed..126edd36 100644 --- a/internal/audiobooks/abs/extras_handlers.go +++ b/internal/audiobooks/abs/extras_handlers.go @@ -136,7 +136,7 @@ func (h *Handler) handleDeleteItemProgress(w http.ResponseWriter, r *http.Reques } contentID := chi.URLParam(r, "libraryItemId") if err := h.deps.ProgressStore.DeleteProgress(r.Context(), a.UserID, a.ProfileID, contentID); err != nil { - slog.Warn("abs delete progress failed", "err", err, "content", contentID) + slog.WarnContext(r.Context(), "abs delete progress failed", "component", "audiobooks", "err", err, "content", contentID) http.Error(w, "delete progress failed", http.StatusInternalServerError) return } diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index 0ac3b63f..b615a8f7 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -620,12 +620,12 @@ func (h *Handler) bearerAuth(next http.Handler) http.Handler { raw = r.URL.Query().Get("token") } if raw == "" { - slog.Debug("abs bearerAuth: no token", "path", r.URL.Path, "remote", r.RemoteAddr) + slog.DebugContext(r.Context(), "abs bearerAuth: no token", "component", "audiobooks", "path", r.URL.Path, "remote", r.RemoteAddr) http.Error(w, "unauthenticated", http.StatusUnauthorized) return } if h.deps.Config == nil || h.deps.TokenStore == nil { - slog.Warn("abs bearerAuth: deps not wired", + slog.WarnContext(r.Context(), "abs bearerAuth: deps not wired", "component", "audiobooks", "have_config", h.deps.Config != nil, "have_token_store", h.deps.TokenStore != nil, "path", r.URL.Path) @@ -634,50 +634,50 @@ func (h *Handler) bearerAuth(next http.Handler) http.Handler { } secret, err := h.deps.Config.JWTSecret(r.Context()) if err != nil { - slog.Error("abs bearerAuth: jwt secret fetch failed", "err", err, "path", r.URL.Path) + slog.ErrorContext(r.Context(), "abs bearerAuth: jwt secret fetch failed", "component", "audiobooks", "err", err, "path", r.URL.Path) http.Error(w, "config unavailable", http.StatusInternalServerError) return } claims, err := ParseToken(secret, raw) if err != nil { - slog.Debug("abs bearerAuth: parse failed", "err", err, "path", r.URL.Path) + slog.DebugContext(r.Context(), "abs bearerAuth: parse failed", "component", "audiobooks", "err", err, "path", r.URL.Path) http.Error(w, "invalid token", http.StatusUnauthorized) return } if claims.Type != "access" { - slog.Debug("abs bearerAuth: wrong token type", "type", claims.Type, "path", r.URL.Path) + slog.DebugContext(r.Context(), "abs bearerAuth: wrong token type", "component", "audiobooks", "type", claims.Type, "path", r.URL.Path) http.Error(w, "invalid token", http.StatusUnauthorized) return } row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI) if err != nil { - slog.Debug("abs bearerAuth: jti lookup failed", + slog.DebugContext(r.Context(), "abs bearerAuth: jti lookup failed", "component", "audiobooks", "jti", claims.JTI, "err", err, "path", r.URL.Path) http.Error(w, "token revoked", http.StatusUnauthorized) return } if row.RevokedAt != nil { - slog.Debug("abs bearerAuth: jti revoked", "jti", claims.JTI, "path", r.URL.Path) + slog.DebugContext(r.Context(), "abs bearerAuth: jti revoked", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path) http.Error(w, "token revoked", http.StatusUnauthorized) return } if row.UserID != "" && row.UserID != claims.UserID { - slog.Debug("abs bearerAuth: token user mismatch", "jti", claims.JTI, "path", r.URL.Path) + slog.DebugContext(r.Context(), "abs bearerAuth: token user mismatch", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path) http.Error(w, "invalid token", http.StatusUnauthorized) return } if row.ProfileID != "" && row.ProfileID != claims.ProfileID { - slog.Debug("abs bearerAuth: token profile mismatch", "jti", claims.JTI, "path", r.URL.Path) + slog.DebugContext(r.Context(), "abs bearerAuth: token profile mismatch", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path) http.Error(w, "invalid token", http.StatusUnauthorized) return } if row.Type != "" && row.Type != "access" { - slog.Debug("abs bearerAuth: persisted token type mismatch", "type", row.Type, "path", r.URL.Path) + slog.DebugContext(r.Context(), "abs bearerAuth: persisted token type mismatch", "component", "audiobooks", "type", row.Type, "path", r.URL.Path) http.Error(w, "invalid token", http.StatusUnauthorized) return } if !row.ExpiresAt.IsZero() && time.Now().After(row.ExpiresAt) { - slog.Debug("abs bearerAuth: persisted token expired", "jti", claims.JTI, "path", r.URL.Path) + slog.DebugContext(r.Context(), "abs bearerAuth: persisted token expired", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path) http.Error(w, "token expired", http.StatusUnauthorized) return } diff --git a/internal/audiobooks/abs/libraries_handler.go b/internal/audiobooks/abs/libraries_handler.go index f5ec3828..ea047db7 100644 --- a/internal/audiobooks/abs/libraries_handler.go +++ b/internal/audiobooks/abs/libraries_handler.go @@ -378,7 +378,7 @@ func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) { return } if total > len(pageAuthors) && r.URL.Query().Get("page") == "" { - slog.Warn("abs authors truncated for non-paginated request", + slog.WarnContext(r.Context(), "abs authors truncated for non-paginated request", "component", "audiobooks", "library", lib.ID, "total", total, "returned", len(pageAuthors)) } libID := audiobookLibraryID(lib) @@ -432,7 +432,7 @@ func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) { return } if total > len(pageSeries) && r.URL.Query().Get("page") == "" { - slog.Warn("abs series truncated for non-paginated request", + slog.WarnContext(r.Context(), "abs series truncated for non-paginated request", "component", "audiobooks", "library", lib.ID, "total", total, "returned", len(pageSeries)) } libID := audiobookLibraryID(lib) diff --git a/internal/audiobooks/abs/listening_stats_handler.go b/internal/audiobooks/abs/listening_stats_handler.go index 665adfd4..f9dcaf30 100644 --- a/internal/audiobooks/abs/listening_stats_handler.go +++ b/internal/audiobooks/abs/listening_stats_handler.go @@ -22,7 +22,7 @@ func (h *Handler) handleListeningStats(w http.ResponseWriter, r *http.Request) { } stats, err := h.deps.PlaybackSessionStore.AggregateStats(r.Context(), a.UserID, a.ProfileID) if err != nil { - slog.Error("abs listening stats failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs listening stats failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "stats unavailable", http.StatusInternalServerError) return } @@ -50,7 +50,7 @@ func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request } sessions, total, err := h.deps.PlaybackSessionStore.ListClosedSessions(r.Context(), a.UserID, a.ProfileID, itemsPerPage, page*itemsPerPage) if err != nil { - slog.Error("abs listening sessions failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs listening sessions failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "sessions unavailable", http.StatusInternalServerError) return } @@ -64,7 +64,7 @@ func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request if h.deps.MediaStore != nil && len(sessions) > 0 { access, aerr := h.accessFilterForAuth(r.Context(), a) if aerr != nil { - slog.Debug("abs listening sessions: resolve access failed", "user", a.UserID, "err", aerr) + slog.DebugContext(r.Context(), "abs listening sessions: resolve access failed", "component", "audiobooks", "user", a.UserID, "err", aerr) } else { contentIDs := make([]string, 0, len(sessions)) for _, s := range sessions { @@ -72,7 +72,7 @@ func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request } hydrated, herr := h.deps.MediaStore.GetAudiobooksByIDs(r.Context(), contentIDs, access) if herr != nil { - slog.Debug("abs listening sessions: hydrate media items failed", "user", a.UserID, "err", herr) + slog.DebugContext(r.Context(), "abs listening sessions: hydrate media items failed", "component", "audiobooks", "user", a.UserID, "err", herr) } else { items = hydrated } @@ -111,9 +111,9 @@ func (h *Handler) handleListeningSessionDetail(w http.ResponseWriter, r *http.Re if h.deps.MediaStore != nil { access, aerr := h.accessFilterForAuth(r.Context(), a) if aerr != nil { - slog.Debug("abs listening session detail: resolve access failed", "user", a.UserID, "err", aerr) + slog.DebugContext(r.Context(), "abs listening session detail: resolve access failed", "component", "audiobooks", "user", a.UserID, "err", aerr) } else if fetched, ferr := h.deps.MediaStore.GetAudiobookByID(r.Context(), sess.ContentID, access); ferr != nil { - slog.Debug("abs listening session detail: hydrate media item failed", "user", a.UserID, "content_id", sess.ContentID, "err", ferr) + slog.DebugContext(r.Context(), "abs listening session detail: hydrate media item failed", "component", "audiobooks", "user", a.UserID, "content_id", sess.ContentID, "err", ferr) } else { item = fetched } diff --git a/internal/audiobooks/abs/login.go b/internal/audiobooks/abs/login.go index 7e2950b0..c95f9a08 100644 --- a/internal/audiobooks/abs/login.go +++ b/internal/audiobooks/abs/login.go @@ -87,7 +87,7 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) if errors.Is(err, auth.ErrInvalidCredentials) || errors.Is(err, auth.ErrUserDisabled) { http.Error(w, "invalid username or password", http.StatusUnauthorized) } else { - slog.Error("abs login: cred validator failed", "username", body.Username, "err", err) + slog.ErrorContext(r.Context(), "abs login: cred validator failed", "component", "audiobooks", "username", body.Username, "err", err) http.Error(w, "login service unavailable", http.StatusServiceUnavailable) } return @@ -102,7 +102,7 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) } } - slog.Debug("abs standalone login: validator OK", + slog.DebugContext(r.Context(), "abs standalone login: validator OK", "component", "audiobooks", "username", body.Username, "user_id", userID, "profile_id", profileID) h.completeLogin(w, r, userID, profileID, displayName) } @@ -175,7 +175,7 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, return } - slog.Debug("abs completeLogin: tokens persisted", + slog.DebugContext(r.Context(), "abs completeLogin: tokens persisted", "component", "audiobooks", "user_id", userID, "access_jti", accessJTI, "refresh_jti", refreshJTI) // Real ABS delivers the refresh token in the body when the client opts in @@ -512,13 +512,13 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { if claims != nil { claimsType = claims.Type } - slog.Debug("abs refresh: parse/type failed", "err", err, "type", claimsType) + slog.DebugContext(r.Context(), "abs refresh: parse/type failed", "component", "audiobooks", "err", err, "type", claimsType) http.Error(w, "invalid refresh token", http.StatusUnauthorized) return } row, err := h.deps.TokenStore.RevokeTokenIfActive(r.Context(), claims.JTI) if err != nil { - slog.Debug("abs refresh: jti revoke failed", "jti", claims.JTI, "err", err) + slog.DebugContext(r.Context(), "abs refresh: jti revoke failed", "component", "audiobooks", "jti", claims.JTI, "err", err) if errors.Is(err, ErrNotFound) { http.Error(w, "refresh token revoked", http.StatusUnauthorized) } else { @@ -556,13 +556,13 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { newRefreshJTI := ulid.Make().String() access, err := IssueAccessToken(secret, claims.UserID, claims.ProfileID, newAccessJTI, accessTTL) if err != nil { - slog.Error("abs refresh: mint access failed", "user", claims.UserID, "err", err) + slog.ErrorContext(r.Context(), "abs refresh: mint access failed", "component", "audiobooks", "user", claims.UserID, "err", err) http.Error(w, "token mint failed", http.StatusInternalServerError) return } refresh, err := IssueRefreshToken(secret, claims.UserID, claims.ProfileID, newRefreshJTI, refreshTTL) if err != nil { - slog.Error("abs refresh: mint refresh failed", "user", claims.UserID, "err", err) + slog.ErrorContext(r.Context(), "abs refresh: mint refresh failed", "component", "audiobooks", "user", claims.UserID, "err", err) http.Error(w, "token mint failed", http.StatusInternalServerError) return } @@ -571,7 +571,7 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { ID: newAccessJTI, UserID: claims.UserID, ProfileID: claims.ProfileID, Type: "access", JTI: newAccessJTI, ExpiresAt: now.Add(accessTTL), }); err != nil { - slog.Error("abs refresh: persist access failed", "user", claims.UserID, "jti", newAccessJTI, "err", err) + slog.ErrorContext(r.Context(), "abs refresh: persist access failed", "component", "audiobooks", "user", claims.UserID, "jti", newAccessJTI, "err", err) http.Error(w, "token persist failed", http.StatusInternalServerError) return } @@ -579,11 +579,11 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { ID: newRefreshJTI, UserID: claims.UserID, ProfileID: claims.ProfileID, Type: "refresh", JTI: newRefreshJTI, ExpiresAt: now.Add(refreshTTL), }); err != nil { - slog.Error("abs refresh: persist refresh failed", "user", claims.UserID, "jti", newRefreshJTI, "err", err) + slog.ErrorContext(r.Context(), "abs refresh: persist refresh failed", "component", "audiobooks", "user", claims.UserID, "jti", newRefreshJTI, "err", err) http.Error(w, "token persist failed", http.StatusInternalServerError) return } - slog.Debug("abs refresh: rotated", "user", claims.UserID, + slog.DebugContext(r.Context(), "abs refresh: rotated", "component", "audiobooks", "user", claims.UserID, "old_jti", claims.JTI, "new_access_jti", newAccessJTI, "new_refresh_jti", newRefreshJTI) // Real ABS /auth/refresh returns the SAME payload as /login. Return the @@ -639,22 +639,22 @@ func (h *Handler) revokeLogoutPrincipal(r *http.Request) { } secret, err := h.deps.Config.JWTSecret(r.Context()) if err != nil { - slog.Debug("abs logout: jwt secret fetch failed", "err", err) + slog.DebugContext(r.Context(), "abs logout: jwt secret fetch failed", "component", "audiobooks", "err", err) return } claims, err := ParseToken(secret, raw) if err != nil || claims.JTI == "" { - slog.Debug("abs logout: parse failed", "err", err) + slog.DebugContext(r.Context(), "abs logout: parse failed", "component", "audiobooks", "err", err) return } if err := h.deps.TokenStore.RevokeTokensForPrincipal(r.Context(), claims.UserID, claims.ProfileID); err != nil { - slog.Warn("abs logout: revoke principal tokens failed", "jti", claims.JTI, "user", claims.UserID, "err", err) + slog.WarnContext(r.Context(), "abs logout: revoke principal tokens failed", "component", "audiobooks", "jti", claims.JTI, "user", claims.UserID, "err", err) return } if h.deps.PlaybackSessionStore != nil { if err := h.deps.PlaybackSessionStore.CloseOpenSessionsForPrincipal(r.Context(), claims.UserID, claims.ProfileID); err != nil { - slog.Warn("abs logout: close sessions failed", "user", claims.UserID, "profile", claims.ProfileID, "err", err) + slog.WarnContext(r.Context(), "abs logout: close sessions failed", "component", "audiobooks", "user", claims.UserID, "profile", claims.ProfileID, "err", err) } } - slog.Debug("abs logout: revoked principal tokens", "jti", claims.JTI, "user", claims.UserID) + slog.DebugContext(r.Context(), "abs logout: revoked principal tokens", "component", "audiobooks", "jti", claims.JTI, "user", claims.UserID) } diff --git a/internal/audiobooks/abs/native_sessions.go b/internal/audiobooks/abs/native_sessions.go index 9345353f..aac17492 100644 --- a/internal/audiobooks/abs/native_sessions.go +++ b/internal/audiobooks/abs/native_sessions.go @@ -63,7 +63,7 @@ func (h *Handler) startNativePlaybackSession( } if err := h.deps.NativeSessions.SetProgressPersistenceDisabled(session.ID, true); err != nil { - slog.Warn("abs play: disable native progress persistence failed", + slog.WarnContext(r.Context(), "abs play: disable native progress persistence failed", "component", "audiobooks", "session_id", session.ID, "error", err) } else { session.DisableProgressPersistence = true @@ -71,7 +71,7 @@ func (h *Handler) startNativePlaybackSession( if startPosition > 0 { if err := h.deps.NativeSessions.UpdateProgress(session.ID, startPosition, false); err != nil { - slog.Warn("abs play: seed native session progress failed", + slog.WarnContext(r.Context(), "abs play: seed native session progress failed", "component", "audiobooks", "session_id", session.ID, "position", startPosition, "error", err) } else { session.Position = startPosition @@ -92,7 +92,7 @@ func (h *Handler) startNativePlaybackSession( ClientUserAgent: session.ClientUserAgent, StreamBitrateKbps: streamBitrateKbps, }); err != nil { - slog.Warn("abs play: update native session stream state failed", + slog.WarnContext(r.Context(), "abs play: update native session stream state failed", "component", "audiobooks", "session_id", session.ID, "error", err) } else { session.PlayMethod = playback.PlayDirect @@ -111,7 +111,7 @@ func (h *Handler) updateNativePlaybackProgress(ctx context.Context, sessionID st } if err := h.deps.NativeSessions.UpdateProgress(sessionID, position, false); err != nil { if !errors.Is(err, playback.ErrSessionNotFound) { - slog.Warn("abs session sync: update native session progress failed", + slog.WarnContext(ctx, "abs session sync: update native session progress failed", "component", "audiobooks", "session_id", sessionID, "position", position, "error", err) } return @@ -125,7 +125,7 @@ func (h *Handler) stopNativePlaybackSession(ctx context.Context, sessionID strin } if err := h.deps.NativeSessions.StopSession(sessionID); err != nil { if !errors.Is(err, playback.ErrSessionNotFound) { - slog.Warn("abs session close: stop native session failed", + slog.WarnContext(ctx, "abs session close: stop native session failed", "component", "audiobooks", "session_id", sessionID, "error", err) } return @@ -162,7 +162,7 @@ func (h *Handler) syncNativeSessionsNow(ctx context.Context, reason string) { return } if err := h.deps.NativeSessionSyncer.SyncNow(ctx); err != nil { - slog.Error("abs: failed to sync native playback sessions", "reason", reason, "error", err) + slog.ErrorContext(ctx, "abs: failed to sync native playback sessions", "component", "audiobooks", "reason", reason, "error", err) } } diff --git a/internal/audiobooks/abs/play_response.go b/internal/audiobooks/abs/play_response.go index 77fd1133..f223e6c0 100644 --- a/internal/audiobooks/abs/play_response.go +++ b/internal/audiobooks/abs/play_response.go @@ -72,7 +72,7 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) { var currentTime float64 currentTime, err = resolveResumeTime(r.Context(), h.deps.ProgressStore, a.UserID, a.ProfileID, contentID) if err != nil { - slog.Debug("play: progress lookup failed", "user", a.UserID, "item", contentID, "err", err) + slog.DebugContext(r.Context(), "play: progress lookup failed", "component", "audiobooks", "user", a.UserID, "item", contentID, "err", err) // currentTime is already 0 on error path; safe to continue. } @@ -107,7 +107,7 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) { // Non-fatal: log but still return the manifest so the client // can play. Heartbeat/close calls will fail with 404 until the // next play_start lands cleanly. - slog.Warn("abs play: persist session failed", + slog.WarnContext(r.Context(), "abs play: persist session failed", "component", "audiobooks", "session_id", sessionID, "content_id", contentID, "err", err) } } diff --git a/internal/audiobooks/abs/playlists_handler.go b/internal/audiobooks/abs/playlists_handler.go index b51dcafe..ca16490d 100644 --- a/internal/audiobooks/abs/playlists_handler.go +++ b/internal/audiobooks/abs/playlists_handler.go @@ -74,7 +74,7 @@ func (h *Handler) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) { p.IsPublic = *body.IsPublic } if err := h.deps.PlaylistStore.CreatePlaylist(r.Context(), p); err != nil { - slog.Error("abs playlist create failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs playlist create failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "playlist persist failed", http.StatusInternalServerError) return } @@ -98,12 +98,12 @@ func (h *Handler) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) { // MediaStore lookup so typos don't create orphan rows. if it.EpisodeID == "" { if mi, mErr := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access); mErr != nil || mi == nil { - slog.Debug("abs playlist create-items: skipping unknown audiobook", "id", it.LibraryItemID) + slog.DebugContext(r.Context(), "abs playlist create-items: skipping unknown audiobook", "component", "audiobooks", "id", it.LibraryItemID) continue } } if addErr := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), p.ID, it.LibraryItemID, it.EpisodeID); addErr != nil { - slog.Debug("abs playlist create-items: store error", "err", addErr, "id", it.LibraryItemID) + slog.DebugContext(r.Context(), "abs playlist create-items: store error", "component", "audiobooks", "err", addErr, "id", it.LibraryItemID) } } @@ -139,7 +139,7 @@ func (h *Handler) playlistItems(r *http.Request, playlistID string) []map[string } rows, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID) if err != nil { - slog.Warn("abs playlist list-items failed", "err", err, "playlist", playlistID) + slog.WarnContext(r.Context(), "abs playlist list-items failed", "component", "audiobooks", "err", err, "playlist", playlistID) return []map[string]any{} } access, _, _ := h.accessFilterFromRequest(r) @@ -197,7 +197,7 @@ func (h *Handler) handleListLibraryPlaylists(w http.ResponseWriter, r *http.Requ } rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID) if err != nil { - slog.Error("abs library playlist list failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs library playlist list failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "playlist list failed", http.StatusInternalServerError) return } @@ -227,7 +227,7 @@ func (h *Handler) handleListPlaylists(w http.ResponseWriter, r *http.Request) { } rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID) if err != nil { - slog.Error("abs playlist list failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs playlist list failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "playlist list failed", http.StatusInternalServerError) return } @@ -257,7 +257,7 @@ func (h *Handler) handleGetPlaylist(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs playlist get failed", "err", err) + slog.ErrorContext(r.Context(), "abs playlist get failed", "component", "audiobooks", "err", err) http.Error(w, "playlist get failed", http.StatusInternalServerError) return } @@ -283,7 +283,7 @@ func (h *Handler) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs playlist get-for-update failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist get-for-update failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist get failed", http.StatusInternalServerError) return } @@ -306,7 +306,7 @@ func (h *Handler) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) { p.IsPublic = *body.IsPublic } if err := h.deps.PlaylistStore.UpdatePlaylist(r.Context(), p); err != nil { - slog.Error("abs playlist update failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist update failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist persist failed", http.StatusInternalServerError) return } @@ -342,7 +342,7 @@ func (h *Handler) handleAddPlaylistItem(w http.ResponseWriter, r *http.Request) return } if err != nil { - slog.Error("abs playlist get-for-add failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist get-for-add failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist get failed", http.StatusInternalServerError) return } @@ -372,7 +372,7 @@ func (h *Handler) handleAddPlaylistItem(w http.ResponseWriter, r *http.Request) } if err := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), id, body.LibraryItemID, body.EpisodeID); err != nil { - slog.Error("abs playlist add-item failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist add-item failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist persist failed", http.StatusInternalServerError) return } @@ -405,12 +405,12 @@ func (h *Handler) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs playlist get-for-delete failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist get-for-delete failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist get failed", http.StatusInternalServerError) return } if err := h.deps.PlaylistStore.DeletePlaylist(r.Context(), id); err != nil { - slog.Error("abs playlist delete failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist delete failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist delete failed", http.StatusInternalServerError) return } @@ -446,7 +446,7 @@ func (h *Handler) handleBatchAddPlaylistItems(w http.ResponseWriter, r *http.Req return } if err != nil { - slog.Error("abs playlist get-for-batch-add failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist get-for-batch-add failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist get failed", http.StatusInternalServerError) return } @@ -459,24 +459,24 @@ func (h *Handler) handleBatchAddPlaylistItems(w http.ResponseWriter, r *http.Req for _, it := range body.Items { if it.LibraryItemID == "" { - slog.Debug("abs playlist batch-add: skipping empty libraryItemId") + slog.DebugContext(r.Context(), "abs playlist batch-add: skipping empty libraryItemId", slog.String("component", "audiobooks")) continue } // Audiobook validation; episode items skip. if it.EpisodeID == "" { access, accessErr := h.accessFilterForAuth(r.Context(), a) if accessErr != nil { - slog.Debug("abs playlist batch-add: skipping access-denied audiobook", "id", it.LibraryItemID, "err", accessErr) + slog.DebugContext(r.Context(), "abs playlist batch-add: skipping access-denied audiobook", "component", "audiobooks", "id", it.LibraryItemID, "err", accessErr) continue } item, lookupErr := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID, access) if lookupErr != nil || item == nil { - slog.Debug("abs playlist batch-add: skipping unknown audiobook", "id", it.LibraryItemID) + slog.DebugContext(r.Context(), "abs playlist batch-add: skipping unknown audiobook", "component", "audiobooks", "id", it.LibraryItemID) continue } } if addErr := h.deps.PlaylistStore.AddPlaylistItem(r.Context(), id, it.LibraryItemID, it.EpisodeID); addErr != nil { - slog.Debug("abs playlist batch-add: store error", "err", addErr, "id", it.LibraryItemID) + slog.DebugContext(r.Context(), "abs playlist batch-add: store error", "component", "audiobooks", "err", addErr, "id", it.LibraryItemID) } } @@ -508,7 +508,7 @@ func (h *Handler) handleBatchRemovePlaylistItems(w http.ResponseWriter, r *http. return } if err != nil { - slog.Error("abs playlist get-for-batch-remove failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist get-for-batch-remove failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist get failed", http.StatusInternalServerError) return } @@ -521,7 +521,7 @@ func (h *Handler) handleBatchRemovePlaylistItems(w http.ResponseWriter, r *http. for _, it := range body.Items { if rmErr := h.deps.PlaylistStore.RemovePlaylistItem(r.Context(), id, it.LibraryItemID, it.EpisodeID); rmErr != nil { - slog.Debug("abs playlist batch-remove: store error", "err", rmErr, "id", it.LibraryItemID) + slog.DebugContext(r.Context(), "abs playlist batch-remove: store error", "component", "audiobooks", "err", rmErr, "id", it.LibraryItemID) } } @@ -577,13 +577,13 @@ func (h *Handler) removePlaylistItemImpl(w http.ResponseWriter, r *http.Request, return } if err != nil { - slog.Error("abs playlist get-for-remove failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs playlist get-for-remove failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "playlist get failed", http.StatusInternalServerError) return } if err := h.deps.PlaylistStore.RemovePlaylistItem(r.Context(), id, libItem, episodeIDFromURL); err != nil { - slog.Error("abs playlist remove-item failed", "err", err, "id", id, "item", libItem, "episode", episodeIDFromURL) + slog.ErrorContext(r.Context(), "abs playlist remove-item failed", "component", "audiobooks", "err", err, "id", id, "item", libItem, "episode", episodeIDFromURL) http.Error(w, "playlist delete failed", http.StatusInternalServerError) return } @@ -615,14 +615,14 @@ func (h *Handler) removePlaylistItemImpl(w http.ResponseWriter, r *http.Request, func (h *Handler) autoDeleteIfEmpty(w http.ResponseWriter, r *http.Request, userID, playlistID string, original Playlist) bool { items, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID) if err != nil { - slog.Warn("abs playlist auto-delete: count failed", "err", err, "id", playlistID) + slog.WarnContext(r.Context(), "abs playlist auto-delete: count failed", "component", "audiobooks", "err", err, "id", playlistID) return false } if len(items) > 0 { return false } if err := h.deps.PlaylistStore.DeletePlaylist(r.Context(), playlistID); err != nil { - slog.Warn("abs playlist auto-delete: delete failed", "err", err, "id", playlistID) + slog.WarnContext(r.Context(), "abs playlist auto-delete: delete failed", "component", "audiobooks", "err", err, "id", playlistID) return false } h.publish(userID, "playlist_removed", map[string]any{"id": playlistID}) diff --git a/internal/audiobooks/abs/progress.go b/internal/audiobooks/abs/progress.go index 2804efff..0f795995 100644 --- a/internal/audiobooks/abs/progress.go +++ b/internal/audiobooks/abs/progress.go @@ -372,7 +372,7 @@ func (h *Handler) handleSessionSync(w http.ResponseWriter, r *http.Request) { if err := h.deps.ProgressStore.UpdateProgressPosition( r.Context(), a.UserID, a.ProfileID, sess.ContentID, p.CurrentTime, ); err != nil { - slog.Warn("abs session sync: update progress position failed", + slog.WarnContext(r.Context(), "abs session sync: update progress position failed", "component", "audiobooks", "session_id", sid, "content_id", sess.ContentID, "error", err) } } diff --git a/internal/audiobooks/abs/rss_feeds_handler.go b/internal/audiobooks/abs/rss_feeds_handler.go index db4034bb..47678dcc 100644 --- a/internal/audiobooks/abs/rss_feeds_handler.go +++ b/internal/audiobooks/abs/rss_feeds_handler.go @@ -37,7 +37,7 @@ func (h *Handler) handleListRSSFeeds(w http.ResponseWriter, r *http.Request) { } rows, err := h.deps.RSSFeedStore.ListUserFeeds(r.Context(), a.UserID, a.ProfileID) if err != nil { - slog.Error("abs feed list failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs feed list failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "feed list failed", http.StatusInternalServerError) return } @@ -95,7 +95,7 @@ func (h *Handler) handleOpenItemFeed(w http.ResponseWriter, r *http.Request) { http.Error(w, "slug taken", http.StatusConflict) return } - slog.Error("abs feed create failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs feed create failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "feed persist failed", http.StatusInternalServerError) return } @@ -125,12 +125,12 @@ func (h *Handler) handleCloseFeed(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs feed get-for-close failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs feed get-for-close failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "feed get failed", http.StatusInternalServerError) return } if err := h.deps.RSSFeedStore.CloseFeed(r.Context(), id); err != nil { - slog.Error("abs feed close failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs feed close failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "feed close failed", http.StatusInternalServerError) return } @@ -161,7 +161,7 @@ func (h *Handler) handlePublicFeed(w http.ResponseWriter, r *http.Request) { return } if err != nil { - slog.Error("abs public feed get failed", "err", err, "slug", slug) + slog.ErrorContext(r.Context(), "abs public feed get failed", "component", "audiobooks", "err", err, "slug", slug) http.Error(w, "feed get failed", http.StatusInternalServerError) return } diff --git a/internal/audiobooks/abs/session_local.go b/internal/audiobooks/abs/session_local.go index ffcd2387..77b90b6a 100644 --- a/internal/audiobooks/abs/session_local.go +++ b/internal/audiobooks/abs/session_local.go @@ -120,7 +120,7 @@ func (h *Handler) handleSyncLocalSessions(w http.ResponseWriter, r *http.Request for _, raw := range body.Sessions { var sess localPlaybackSession if err := json.Unmarshal(raw, &sess); err != nil { - slog.Warn("abs local session sync: skipping malformed session", "error", err) + slog.WarnContext(r.Context(), "abs local session sync: skipping malformed session", "component", "audiobooks", "error", err) results = append(results, localSyncResult{Success: false, Error: "invalid session"}) continue } @@ -155,7 +155,7 @@ func (h *Handler) syncOneLocalSession(ctx context.Context, a ctxAuth, access cat item, err := h.deps.MediaStore.GetAudiobookByID(ctx, sess.LibraryItemID, access) if err != nil || item == nil { if err != nil { - slog.Warn("abs local session sync: media lookup failed", + slog.WarnContext(ctx, "abs local session sync: media lookup failed", "component", "audiobooks", "library_item_id", sess.LibraryItemID, "error", err) } res.Error = "Media item not found" @@ -195,7 +195,7 @@ func (h *Handler) syncOneLocalSession(ctx context.Context, a ctxAuth, access cat ) } if syncErr != nil { - slog.Warn("abs local session sync: persist progress position failed", + slog.WarnContext(ctx, "abs local session sync: persist progress position failed", "component", "audiobooks", "library_item_id", sess.LibraryItemID, "error", syncErr) } else { res.ProgressSynced = true diff --git a/internal/audiobooks/abs/smart_collections_handler.go b/internal/audiobooks/abs/smart_collections_handler.go index bab0fb99..2fc4cad2 100644 --- a/internal/audiobooks/abs/smart_collections_handler.go +++ b/internal/audiobooks/abs/smart_collections_handler.go @@ -76,14 +76,14 @@ func (h *Handler) handleCreateSmartCollection(w http.ResponseWriter, r *http.Req } qdBytes, err := json.Marshal(qd) if err != nil { - slog.Error("abs smart collection marshal query_def failed", "err", err) + slog.ErrorContext(r.Context(), "abs smart collection marshal query_def failed", "component", "audiobooks", "err", err) http.Error(w, "smart collection persist failed", http.StatusInternalServerError) return } c.QueryDef = qdBytes if err := h.deps.SmartCollectionStore.CreateSmartCollection(r.Context(), c); err != nil { - slog.Error("abs smart collection create failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs smart collection create failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "smart collection persist failed", http.StatusInternalServerError) return } @@ -107,7 +107,7 @@ func (h *Handler) handleListSmartCollections(w http.ResponseWriter, r *http.Requ } rows, err := h.deps.SmartCollectionStore.ListUserSmartCollections(r.Context(), a.UserID, a.ProfileID) if err != nil { - slog.Error("abs smart collection list failed", "err", err, "user", a.UserID) + slog.ErrorContext(r.Context(), "abs smart collection list failed", "component", "audiobooks", "err", err, "user", a.UserID) http.Error(w, "smart collection list failed", http.StatusInternalServerError) return } @@ -134,7 +134,7 @@ func (h *Handler) handleGetSmartCollection(w http.ResponseWriter, r *http.Reques return } if err != nil { - slog.Error("abs smart collection get failed", "err", err) + slog.ErrorContext(r.Context(), "abs smart collection get failed", "component", "audiobooks", "err", err) http.Error(w, "smart collection get failed", http.StatusInternalServerError) return } @@ -158,7 +158,7 @@ func (h *Handler) handleUpdateSmartCollection(w http.ResponseWriter, r *http.Req return } if err != nil { - slog.Error("abs smart collection get-for-update failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs smart collection get-for-update failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "smart collection get failed", http.StatusInternalServerError) return } @@ -191,14 +191,14 @@ func (h *Handler) handleUpdateSmartCollection(w http.ResponseWriter, r *http.Req } qdBytes, mErr := json.Marshal(qd) if mErr != nil { - slog.Error("abs smart collection marshal query_def failed", "err", mErr) + slog.ErrorContext(r.Context(), "abs smart collection marshal query_def failed", "component", "audiobooks", "err", mErr) http.Error(w, "smart collection persist failed", http.StatusInternalServerError) return } c.QueryDef = qdBytes } if err := h.deps.SmartCollectionStore.UpdateSmartCollection(r.Context(), c); err != nil { - slog.Error("abs smart collection update failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs smart collection update failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "smart collection persist failed", http.StatusInternalServerError) return } @@ -226,12 +226,12 @@ func (h *Handler) handleDeleteSmartCollection(w http.ResponseWriter, r *http.Req return } if err != nil { - slog.Error("abs smart collection get-for-delete failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs smart collection get-for-delete failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "smart collection get failed", http.StatusInternalServerError) return } if err := h.deps.SmartCollectionStore.DeleteSmartCollection(r.Context(), id); err != nil { - slog.Error("abs smart collection delete failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs smart collection delete failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "smart collection delete failed", http.StatusInternalServerError) return } @@ -260,7 +260,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ return } if err != nil { - slog.Error("abs smart collection items get failed", "err", err, "id", id) + slog.ErrorContext(r.Context(), "abs smart collection items get failed", "component", "audiobooks", "err", err, "id", id) http.Error(w, "smart collection get failed", http.StatusInternalServerError) return } @@ -268,7 +268,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ var qd smartcoll.QueryDefinition if len(c.QueryDef) > 0 { if uErr := json.Unmarshal(c.QueryDef, &qd); uErr != nil { - slog.Error("abs smart collection invalid stored query_def", "err", uErr, "id", id) + slog.ErrorContext(r.Context(), "abs smart collection invalid stored query_def", "component", "audiobooks", "err", uErr, "id", id) http.Error(w, "smart collection get failed", http.StatusInternalServerError) return } @@ -287,7 +287,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ } allLibs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context(), access) if err != nil { - slog.Warn("abs smart collection libraries fetch failed", "err", err, "id", id) + slog.WarnContext(r.Context(), "abs smart collection libraries fetch failed", "component", "audiobooks", "err", err, "id", id) allLibs = nil } libByID := make(map[int64]AudiobookLibrary, len(allLibs)) @@ -327,7 +327,7 @@ func (h *Handler) handleSmartCollectionItems(w http.ResponseWriter, r *http.Requ for _, lib := range targetLibs { items, _, lerr := h.deps.MediaStore.ListAudiobooks(r.Context(), lib.ID, 0, 0, access, Filter{}) if lerr != nil { - slog.Warn("abs smart collection list-audiobooks failed", "err", lerr, "library", lib.ID) + slog.WarnContext(r.Context(), "abs smart collection list-audiobooks failed", "component", "audiobooks", "err", lerr, "library", lib.ID) continue } for _, mi := range items { diff --git a/internal/audiobooks/enrichment.go b/internal/audiobooks/enrichment.go index 93ec9ede..08da71e2 100644 --- a/internal/audiobooks/enrichment.go +++ b/internal/audiobooks/enrichment.go @@ -185,14 +185,14 @@ func (e *Enricher) Run(ctx context.Context) (int, error) { return 0, nil } - slog.Info("audiobook enrichment: sweep started", + slog.InfoContext(ctx, "audiobook enrichment: sweep started", "component", "audiobooks", "count", len(items), "workers", e.workers, ) enriched := e.runBatch(ctx, items, e.enrichItem) - slog.Info("audiobook enrichment: sweep complete", + slog.InfoContext(ctx, "audiobook enrichment: sweep complete", "component", "audiobooks", "attempted", len(items), "enriched", enriched, ) @@ -251,7 +251,7 @@ func (e *Enricher) runBatch(ctx context.Context, items []enrichmentItemRow, enri continue // drain } if err := enrichFn(ctx, item); err != nil { - slog.Warn("audiobook enrichment: item failed", + slog.WarnContext(ctx, "audiobook enrichment: item failed", "component", "audiobooks", "content_id", item.ContentID, "title", item.Title, "error", err, @@ -354,7 +354,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error defer e.maybeApplyCoverFallback(ctx, item.ContentID) if item.FolderID == 0 { - slog.Debug("audiobook enrichment: item has no library folder, skipping", + slog.DebugContext(ctx, "audiobook enrichment: item has no library folder, skipping", "component", "audiobooks", "content_id", item.ContentID, "title", item.Title, ) @@ -367,7 +367,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error return fmt.Errorf("resolving audiobook chain for folder %d: %w", item.FolderID, err) } if len(providers) == 0 { - slog.Debug("audiobook enrichment: no providers in chain", + slog.DebugContext(ctx, "audiobook enrichment: no providers in chain", "component", "audiobooks", "content_id", item.ContentID, "folder_id", item.FolderID, ) @@ -403,7 +403,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error } results, searchErr := sp.Search(ctx, searchQuery) if searchErr != nil { - slog.Warn("audiobook enrichment: search error", + slog.WarnContext(ctx, "audiobook enrichment: search error", "component", "audiobooks", "provider", p.Slug(), "content_id", item.ContentID, "error", searchErr, @@ -422,7 +422,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error } } } - slog.Debug("audiobook enrichment: search result", + slog.DebugContext(ctx, "audiobook enrichment: search result", "component", "audiobooks", "provider", p.Slug(), "content_id", item.ContentID, "matched_ids", accumulatedIDs, @@ -445,7 +445,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error Language: item.Language, }) if getErr != nil { - slog.Warn("audiobook enrichment: GetMetadata error", + slog.WarnContext(ctx, "audiobook enrichment: GetMetadata error", "component", "audiobooks", "provider", p.Slug(), "content_id", item.ContentID, "error", getErr, @@ -461,7 +461,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error accumulatedIDs = accumulator.ProviderIDs metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty) - slog.Debug("audiobook enrichment: metadata received", + slog.DebugContext(ctx, "audiobook enrichment: metadata received", "component", "audiobooks", "provider", p.Slug(), "content_id", item.ContentID, "has_poster", result.PosterPath != "", @@ -483,7 +483,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error } // Providers ran cleanly but nothing matched — stamp last_refreshed so we // skip on the next sweep. - slog.Info("audiobook enrichment: no metadata found", + slog.InfoContext(ctx, "audiobook enrichment: no metadata found", "component", "audiobooks", "content_id", item.ContentID, "title", item.Title, ) @@ -497,7 +497,7 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error e.enqueueRemoteArtwork(ctx, item.ContentID, accumulator) e.autoLinkLiteraryWork(ctx, item.ContentID) - slog.Info("audiobook enrichment: enriched", + slog.InfoContext(ctx, "audiobook enrichment: enriched", "component", "audiobooks", "content_id", item.ContentID, "title", item.Title, "poster", accumulator.PosterPath != "", @@ -514,11 +514,11 @@ func (e *Enricher) autoLinkLiteraryWork(ctx context.Context, contentID string) { } workID, linked, err := e.workLinker.AutoLinkContent(ctx, contentID) if err != nil { - slog.Warn("audiobook enrichment: literary work auto-link failed", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "audiobook enrichment: literary work auto-link failed", "component", "audiobooks", "content_id", contentID, "error", err) return } if linked { - slog.Info("audiobook enrichment: literary work auto-linked", "content_id", contentID, "work_id", workID) + slog.InfoContext(ctx, "audiobook enrichment: literary work auto-linked", "component", "audiobooks", "content_id", contentID, "work_id", workID) } } @@ -531,7 +531,7 @@ func (e *Enricher) maybeApplyCoverFallback(ctx context.Context, contentID string return } if err := e.applyLocalCoverFallback(ctx, contentID); err != nil { - slog.Warn("audiobook enrichment: local cover fallback failed", + slog.WarnContext(ctx, "audiobook enrichment: local cover fallback failed", "component", "audiobooks", "content_id", contentID, "error", err, ) @@ -562,7 +562,7 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu ImageType: metadata.ImagePoster, }) if err != nil { - slog.Warn("audiobook enrichment: poster cache failed, keeping provider URL", + slog.WarnContext(ctx, "audiobook enrichment: poster cache failed, keeping provider URL", "component", "audiobooks", "content_id", contentID, "url", result.PosterPath, "error", err, @@ -651,7 +651,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma if e.providerIDs != nil && len(providerIDs) > 0 { if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil { // Non-fatal: log and continue. - slog.Warn("audiobook enrichment: failed to persist provider IDs", + slog.WarnContext(ctx, "audiobook enrichment: failed to persist provider IDs", "component", "audiobooks", "content_id", contentID, "error", err, ) @@ -667,7 +667,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma if len(result.People) > 0 && e.personRepo != nil && e.itemRepo != nil { if err := e.persistPeople(ctx, contentID, result.People); err != nil { // Non-fatal: log and continue so at least scalar metadata is saved. - slog.Warn("audiobook enrichment: failed to persist people", + slog.WarnContext(ctx, "audiobook enrichment: failed to persist people", "component", "audiobooks", "content_id", contentID, "error", err, ) @@ -706,7 +706,7 @@ func (e *Enricher) enqueueRemoteArtwork(ctx context.Context, contentID string, r enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() if _, err := e.imageCacheJobs.EnqueueBatch(enqueueCtx, inputs); err != nil { - slog.Warn("audiobook enrichment: failed to enqueue image cache jobs", + slog.WarnContext(ctx, "audiobook enrichment: failed to enqueue image cache jobs", "component", "audiobooks", "content_id", contentID, "count", len(inputs), "error", err, diff --git a/internal/audiobooks/podcastfeed/refresher.go b/internal/audiobooks/podcastfeed/refresher.go index b33add5e..6e2a2edc 100644 --- a/internal/audiobooks/podcastfeed/refresher.go +++ b/internal/audiobooks/podcastfeed/refresher.go @@ -140,7 +140,7 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) { } attempted++ if err := r.RefreshOne(ctx, s, f); err != nil { - slog.Warn("podcast feed refresh failed", + slog.WarnContext(ctx, "podcast feed refresh failed", "component", "audiobooks", "media_item_id", f.MediaItemID, "feed_url", f.FeedURL, "err", err.Error(), @@ -173,7 +173,7 @@ func (r *Refresher) RefreshOne(ctx context.Context, s Store, f PodcastFeed) erro return err } _ = s.MarkFeedRefreshed(ctx, f.MediaItemID, "") - slog.Debug("podcast feed refreshed", + slog.DebugContext(ctx, "podcast feed refreshed", "component", "audiobooks", "media_item_id", f.MediaItemID, "episodes", count, ) @@ -258,7 +258,7 @@ func (r *Refresher) upsertItems(ctx context.Context, s Store, f PodcastFeed, fee StillPath: coverFromItem(item), } if err := s.UpsertPodcastEpisode(ctx, ep); err != nil { - slog.Warn("podcast episode upsert failed", + slog.WarnContext(ctx, "podcast episode upsert failed", "component", "audiobooks", "media_item_id", f.MediaItemID, "guid", guid, "err", err.Error(), diff --git a/internal/auth/oauth_handler.go b/internal/auth/oauth_handler.go index f41954af..c9834a9a 100644 --- a/internal/auth/oauth_handler.go +++ b/internal/auth/oauth_handler.go @@ -122,7 +122,7 @@ func (h *OAuthHandler) HandleInit(w http.ResponseWriter, r *http.Request) { // Linking is wired in a follow-up — see TODO below. }) if err != nil { - slog.Warn("oauth init_authorize failed", "installation_id", installID, "error", err) + slog.WarnContext(r.Context(), "oauth init_authorize failed", "component", "auth", "installation_id", installID, "error", err) http.Error(w, "plugin init_authorize failed", http.StatusBadGateway) return } @@ -143,7 +143,7 @@ func (h *OAuthHandler) HandleInit(w http.ResponseWriter, r *http.Request) { // and set LinkingUserID here. } if err := h.deps.Store.Insert(r.Context(), sess); err != nil { - slog.Warn("oauth session insert failed", "installation_id", installID, "error", err) + slog.WarnContext(r.Context(), "oauth session insert failed", "component", "auth", "installation_id", installID, "error", err) http.Error(w, "store insert failed", http.StatusInternalServerError) return } @@ -198,7 +198,7 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { ProviderState: psStruct, }) if err != nil { - slog.Warn("oauth exchange_code failed", "installation_id", installID, "error", err) + slog.WarnContext(r.Context(), "oauth exchange_code failed", "component", "auth", "installation_id", installID, "error", err) http.Redirect(w, r, "/login?error=oauth_failed&reason=exchange_failed", http.StatusFound) return } @@ -223,19 +223,19 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { IP: clientIP(r), }) if err != nil { - slog.Warn("oauth login completion failed", "installation_id", installID, "error", err) + slog.WarnContext(r.Context(), "oauth login completion failed", "component", "auth", "installation_id", installID, "error", err) http.Redirect(w, r, "/login?error=oauth_failed&reason=login_failed", http.StatusFound) return } if h.deps.CompletionStore == nil { - slog.Warn("oauth completion store is unavailable", "installation_id", installID) + slog.WarnContext(r.Context(), "oauth completion store is unavailable", "component", "auth", "installation_id", installID) http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_unavailable", http.StatusFound) return } completionCode, err := randomHex(32) if err != nil { - slog.Warn("oauth completion code generation failed", "installation_id", installID, "error", err) + slog.WarnContext(r.Context(), "oauth completion code generation failed", "component", "auth", "installation_id", installID, "error", err) http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound) return } @@ -248,7 +248,7 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { NextURL: sess.NextURL, ExpiresAt: now.Add(time.Minute), }); err != nil { - slog.Warn("oauth completion insert failed", "installation_id", installID, "error", err) + slog.WarnContext(r.Context(), "oauth completion insert failed", "component", "auth", "installation_id", installID, "error", err) http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound) return } diff --git a/internal/autoscan/service.go b/internal/autoscan/service.go index cf99b875..0e727735 100644 --- a/internal/autoscan/service.go +++ b/internal/autoscan/service.go @@ -178,9 +178,9 @@ func (s *Service) PollOnce(ctx context.Context) error { if src.ConnectionID != nil { resolved, cerr := s.resolveConnection(ctx, *src.ConnectionID) if cerr != nil { - slog.WarnContext(ctx, "autoscan: resolve connection failed", "source_id", src.ID, "err", cerr) + slog.WarnContext(ctx, "autoscan: resolve connection failed", "component", "autoscan", "source_id", src.ID, "err", cerr) if rerr := s.store.RecordError(ctx, src.ID, cerr.Error()); rerr != nil { - slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr) + slog.WarnContext(ctx, "autoscan: record error failed", "component", "autoscan", "source_id", src.ID, "err", rerr) } s.finishEvent(ctx, eventID, EventFinish{ Status: EventStatusError, @@ -193,9 +193,9 @@ func (s *Service) PollOnce(ctx context.Context) error { } changes, next, perr := s.provider.PollChanges(ctx, src.PluginID, src.CapabilityID, marker, conn, src.SourceConfig) if perr != nil { - slog.WarnContext(ctx, "autoscan: poll changes failed", "source_id", src.ID, "err", perr) + slog.WarnContext(ctx, "autoscan: poll changes failed", "component", "autoscan", "source_id", src.ID, "err", perr) if rerr := s.store.RecordError(ctx, src.ID, perr.Error()); rerr != nil { - slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr) + slog.WarnContext(ctx, "autoscan: record error failed", "component", "autoscan", "source_id", src.ID, "err", rerr) } s.finishEvent(ctx, eventID, EventFinish{ Status: EventStatusError, @@ -209,7 +209,7 @@ func (s *Service) PollOnce(ctx context.Context) error { targets, claimed, resolvedAny, stats := s.resolveAndClaim(ctx, rewritten, ttl) if len(targets) > maxAutoscanTargetsPerPoll { collapsed := collapseTargetsToLibraryScans(targets) - slog.WarnContext(ctx, "autoscan: collapsed large scan target batch to library scans", + slog.WarnContext(ctx, "autoscan: collapsed large scan target batch to library scans", "component", "autoscan", "source_id", src.ID, "targets", len(targets), "collapsed_targets", len(collapsed), @@ -223,7 +223,7 @@ func (s *Service) PollOnce(ctx context.Context) error { enqueue, eerr = s.enqueueScanTargets(ctx, targets, eventID) if eerr != nil { s.releaseClaims(ctx, claimed) - slog.WarnContext(ctx, "autoscan: enqueue failed", "source_id", src.ID, "err", eerr) + slog.WarnContext(ctx, "autoscan: enqueue failed", "component", "autoscan", "source_id", src.ID, "err", eerr) s.finishEvent(ctx, eventID, EventFinish{ Status: EventStatusError, ChangesReturned: len(changes), @@ -265,7 +265,7 @@ func (s *Service) PollOnce(ctx context.Context) error { if stats.TransientErrors > 0 { msg := fmt.Sprintf("%d resolve attempt(s) failed internally (%d of %d path(s) resolved) — holding marker to retry", stats.TransientErrors, stats.ChangesResolved, len(changes)) if rerr := s.store.RecordError(ctx, src.ID, msg); rerr != nil { - slog.WarnContext(ctx, "autoscan: record error failed", "source_id", src.ID, "err", rerr) + slog.WarnContext(ctx, "autoscan: record error failed", "component", "autoscan", "source_id", src.ID, "err", rerr) } s.finishEvent(ctx, eventID, EventFinish{ Status: EventStatusError, @@ -289,7 +289,7 @@ func (s *Service) PollOnce(ctx context.Context) error { "source_id", src.ID, "changes", len(changes)) } if aerr := s.store.AdvanceMarker(ctx, src.ID, next); aerr != nil { - slog.WarnContext(ctx, "autoscan: advance marker failed", "source_id", src.ID, "err", aerr) + slog.WarnContext(ctx, "autoscan: advance marker failed", "component", "autoscan", "source_id", src.ID, "err", aerr) s.finishEvent(ctx, eventID, EventFinish{ Status: EventStatusError, ChangesReturned: len(changes), @@ -359,10 +359,10 @@ func (s *Service) createEvent(ctx context.Context, src Source, marker string, st }) if err != nil { if errors.Is(err, ErrPollAlreadyRunning) { - slog.DebugContext(ctx, "autoscan: source poll already running", "source_id", src.ID) + slog.DebugContext(ctx, "autoscan: source poll already running", "component", "autoscan", "source_id", src.ID) return 0, false } - slog.WarnContext(ctx, "autoscan: create event failed", "source_id", src.ID, "err", err) + slog.WarnContext(ctx, "autoscan: create event failed", "component", "autoscan", "source_id", src.ID, "err", err) return 0, true } return id, true @@ -378,7 +378,7 @@ func (s *Service) finishEvent(ctx context.Context, eventID int64, finish EventFi finish.Status = EventStatusSuccess } if err := s.store.FinishEvent(ctx, finish); err != nil { - slog.WarnContext(ctx, "autoscan: finish event failed", "event_id", eventID, "err", err) + slog.WarnContext(ctx, "autoscan: finish event failed", "component", "autoscan", "event_id", eventID, "err", err) } } @@ -442,7 +442,7 @@ func (s *Service) resolveAndClaim(ctx context.Context, changes []Change, ttl tim continue } stats.TransientErrors++ - slog.WarnContext(ctx, "autoscan: resolve failed", "path", dir, "err", rerr) + slog.WarnContext(ctx, "autoscan: resolve failed", "component", "autoscan", "path", dir, "err", rerr) continue } if target == nil || target.Folder == nil { @@ -520,7 +520,7 @@ func (s *Service) resolveChange(ctx context.Context, change Change, stats *resol return nil, false } stats.TransientErrors++ - slog.WarnContext(ctx, "autoscan: resolve failed", "path", change.SourcePath, "scope", change.Scope, "err", err) + slog.WarnContext(ctx, "autoscan: resolve failed", "component", "autoscan", "path", change.SourcePath, "scope", change.Scope, "err", err) return nil, false } if target == nil || target.Folder == nil { @@ -559,7 +559,7 @@ func (s *Service) claimTarget( func (s *Service) releaseClaims(ctx context.Context, claimed []string) { for _, k := range claimed { if rerr := s.suppress.Release(ctx, k); rerr != nil { - slog.WarnContext(ctx, "autoscan: release claim failed", "key", k, "err", rerr) + slog.WarnContext(ctx, "autoscan: release claim failed", "component", "autoscan", "key", k, "err", rerr) } } } diff --git a/internal/catalog/collection_sync_scheduler.go b/internal/catalog/collection_sync_scheduler.go index 73eebcd4..48b65703 100644 --- a/internal/catalog/collection_sync_scheduler.go +++ b/internal/catalog/collection_sync_scheduler.go @@ -59,7 +59,7 @@ func (s *CollectionSyncScheduler) RunOnce(ctx context.Context) (json.RawMessage, return marshalResult(CollectionSyncResult{}), nil } - s.logger.Info("collection sync scheduler: starting", + s.logger.InfoContext(ctx, "collection sync scheduler: starting", "due", len(due), ) @@ -81,7 +81,7 @@ func (s *CollectionSyncScheduler) RunOnce(ctx context.Context) (json.RawMessage, _ = g.Wait() - s.logger.Info("collection sync scheduler: complete", + s.logger.InfoContext(ctx, "collection sync scheduler: complete", "due", result.Due, "synced", result.Synced, "failed", result.Failed, @@ -95,7 +95,7 @@ func (s *CollectionSyncScheduler) RunOnce(ctx context.Context) (json.RawMessage, func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *models.LibraryCollection, mu *sync.Mutex, result *CollectionSyncResult) { // Guard against concurrent sync of the same collection (e.g., manual trigger). if _, loaded := s.inFlight.LoadOrStore(collection.ID, struct{}{}); loaded { - s.logger.Info("collection sync scheduler: skipping (already in flight)", + s.logger.InfoContext(ctx, "collection sync scheduler: skipping (already in flight)", "collection_id", collection.ID, "title", collection.Title, ) @@ -117,7 +117,7 @@ func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *model if collection.SyncSchedule != nil { next := ComputeNextSyncAtFrom(*collection.SyncSchedule, completedAt) if err := s.repo.UpdateNextSyncAt(ctx, collection.ID, next); err != nil { - s.logger.Error("collection sync scheduler: failed to advance schedule", + s.logger.ErrorContext(ctx, "collection sync scheduler: failed to advance schedule", "collection_id", collection.ID, "error", err, ) @@ -129,7 +129,7 @@ func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *model if syncErr != nil { result.Failed++ - s.logger.Error("collection sync scheduler: sync failed", + s.logger.ErrorContext(ctx, "collection sync scheduler: sync failed", "collection_id", collection.ID, "title", collection.Title, "duration", completedAt.Sub(startedAt).Round(time.Millisecond), @@ -137,7 +137,7 @@ func (s *CollectionSyncScheduler) syncOne(ctx context.Context, collection *model ) } else { result.Synced++ - s.logger.Info("collection sync scheduler: synced", + s.logger.InfoContext(ctx, "collection sync scheduler: synced", "collection_id", collection.ID, "title", collection.Title, "duration", completedAt.Sub(startedAt).Round(time.Millisecond), diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index 42d06229..dc331051 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -1560,7 +1560,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M if s.groupClaimRepo != nil { paths, err := s.groupClaimRepo.ListObservedRootsByContentID(ctx, contentID) if err != nil { - slog.WarnContext(ctx, "failed to fetch series group locations", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "failed to fetch series group locations", "component", "catalog", "content_id", contentID, "error", err) } else if len(paths) > 0 { detail.FolderPaths = paths } @@ -1568,7 +1568,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M if len(detail.FolderPaths) == 0 && s.rootClaimRepo != nil { roots, err := s.rootClaimRepo.ListByContentID(ctx, contentID) if err != nil { - slog.WarnContext(ctx, "failed to fetch series root claims", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "failed to fetch series root claims", "component", "catalog", "content_id", contentID, "error", err) } else if len(roots) > 0 { paths := make([]string, len(roots)) for i, root := range roots { @@ -1581,7 +1581,7 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M if len(detail.FolderPaths) == 0 && s.fileFetcher != nil { files, err := s.fileFetcher.GetByContentID(ctx, contentID) if err != nil { - slog.WarnContext(ctx, "failed to fetch series files for folder paths", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "failed to fetch series files for folder paths", "component", "catalog", "content_id", contentID, "error", err) } else { detail.FolderPaths = seriesFolderPathsFromFiles(files) } @@ -1844,7 +1844,7 @@ func (s *DetailService) fetchMangaChapters(ctx context.Context, seriesContentID chapters = append(chapters, ch) } if err := rows.Err(); err != nil { - slog.Warn("manga chapters: row iteration error", "series", seriesContentID, "error", err) + slog.WarnContext(ctx, "manga chapters: row iteration error", "component", "catalog", "series", seriesContentID, "error", err) } // Presign every chapter poster in one batch rather than per chapter — a // long-running series has hundreds of chapters. @@ -3350,8 +3350,8 @@ func (s *DetailService) queueWatchPlaybackFiles( return } - slog.Info( - "queueing chapter thumbnails", + slog.InfoContext(ctx, + "queueing chapter thumbnails", "component", "catalog", "source", "watch_detail", "content_id", @@ -3459,7 +3459,7 @@ func (s *DetailService) PresignURLWithExpiry(ctx context.Context, path string, v return ResolvedImageURL{URL: s.imageResolver.ResolveImageURL(ctx, path, variant)} } - slog.Warn("image path could not be resolved", "path", path) + slog.WarnContext(ctx, "image path could not be resolved", "component", "catalog", "path", path) return ResolvedImageURL{} } diff --git a/internal/catalog/library_collection_service.go b/internal/catalog/library_collection_service.go index 744ce206..2368efdf 100644 --- a/internal/catalog/library_collection_service.go +++ b/internal/catalog/library_collection_service.go @@ -463,7 +463,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, return nil, fmt.Errorf("fetching TMDB preset: %w", err) } - slog.Info("TMDB preset sync: fetched results", + slog.InfoContext(ctx, "TMDB preset sync: fetched results", "component", "catalog", "collection_id", collection.ID, "preset", preset, "media_type", mediaType, @@ -486,7 +486,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, return nil, err } if item == nil { - slog.Debug("TMDB preset sync: no match", + slog.DebugContext(ctx, "TMDB preset sync: no match", "component", "catalog", "rank", i+1, "title", entry.Title, "type", entry.MediaType, @@ -499,7 +499,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, continue } if firstRank, exists := seenContentIDs[item.ContentID]; exists { - slog.Debug("TMDB preset sync: duplicate match skipped", + slog.DebugContext(ctx, "TMDB preset sync: duplicate match skipped", "component", "catalog", "rank", i+1, "title", entry.Title, "type", entry.MediaType, @@ -515,7 +515,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, } seenContentIDs[item.ContentID] = i + 1 - slog.Debug("TMDB preset sync: matched", + slog.DebugContext(ctx, "TMDB preset sync: matched", "component", "catalog", "rank", i+1, "title", entry.Title, "type", entry.MediaType, @@ -535,7 +535,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, } } - slog.Info("TMDB preset sync: complete", + slog.InfoContext(ctx, "TMDB preset sync: complete", "component", "catalog", "collection_id", collection.ID, "preset", preset, "matched", len(matchedItems), @@ -629,7 +629,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte return nil, fmt.Errorf("fetching TMDB collection: %w", err) } - slog.Info("TMDB franchise sync: fetched results", + slog.InfoContext(ctx, "TMDB franchise sync: fetched results", "component", "catalog", "collection_id", collection.ID, "tmdb_collection_id", cfg.CollectionID, "count", len(results), @@ -650,7 +650,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte return nil, err } if item == nil { - slog.Debug("TMDB franchise sync: no match", + slog.DebugContext(ctx, "TMDB franchise sync: no match", "component", "catalog", "rank", i+1, "title", entry.Title, "tmdb_id", entry.ID, @@ -661,7 +661,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte continue } if firstRank, exists := seenContentIDs[item.ContentID]; exists { - slog.Debug("TMDB franchise sync: duplicate match skipped", + slog.DebugContext(ctx, "TMDB franchise sync: duplicate match skipped", "component", "catalog", "rank", i+1, "title", entry.Title, "content_id", item.ContentID, @@ -673,7 +673,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte } seenContentIDs[item.ContentID] = i + 1 - slog.Debug("TMDB franchise sync: matched", + slog.DebugContext(ctx, "TMDB franchise sync: matched", "component", "catalog", "rank", i+1, "title", entry.Title, "tmdb_id", entry.ID, @@ -690,7 +690,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte } } - slog.Info("TMDB franchise sync: complete", + slog.InfoContext(ctx, "TMDB franchise sync: complete", "component", "catalog", "collection_id", collection.ID, "tmdb_collection_id", cfg.CollectionID, "matched", len(matchedItems), @@ -803,7 +803,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex return nil, fmt.Errorf("fetching TMDB discover: %w", err) } - slog.Info("TMDB discover sync: fetched results", + slog.InfoContext(ctx, "TMDB discover sync: fetched results", "component", "catalog", "collection_id", collection.ID, "media_type", mediaType, "sort_by", params.SortBy, @@ -825,7 +825,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex return nil, err } if item == nil { - slog.Debug("TMDB discover sync: no match", + slog.DebugContext(ctx, "TMDB discover sync: no match", "component", "catalog", "rank", i+1, "title", entry.Title, "type", entry.MediaType, @@ -838,7 +838,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex continue } if firstRank, exists := seenContentIDs[item.ContentID]; exists { - slog.Debug("TMDB discover sync: duplicate match skipped", + slog.DebugContext(ctx, "TMDB discover sync: duplicate match skipped", "component", "catalog", "rank", i+1, "title", entry.Title, "content_id", item.ContentID, @@ -861,7 +861,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex } } - slog.Info("TMDB discover sync: complete", + slog.InfoContext(ctx, "TMDB discover sync: complete", "component", "catalog", "collection_id", collection.ID, "media_type", mediaType, "matched", len(matchedItems), @@ -941,7 +941,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context return s.recordFailedCollectionSync(ctx, collection.ID, startedAt, "Trakt recommendations require a profile") } if s.TraktTokenResolver == nil { - slog.Error("Trakt recommendations: token resolver not configured", + slog.ErrorContext(ctx, "Trakt recommendations: token resolver not configured", "component", "catalog", "collection_id", collection.ID, "profile_id", profileID, ) @@ -949,7 +949,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context } token, err := s.TraktTokenResolver.ResolveTraktAccessToken(ctx, profileID) if err != nil { - slog.Error("Trakt recommendations: failed to resolve access token", + slog.ErrorContext(ctx, "Trakt recommendations: failed to resolve access token", "component", "catalog", "collection_id", collection.ID, "profile_id", profileID, "error", err, @@ -965,7 +965,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context return nil, fmt.Errorf("fetching Trakt preset: %w", err) } - slog.Info("Trakt preset sync: fetched results", + slog.InfoContext(ctx, "Trakt preset sync: fetched results", "component", "catalog", "collection_id", collection.ID, "preset", preset, "media_type", mediaType, @@ -1003,7 +1003,7 @@ func (s *LibraryCollectionService) syncTraktListCollection(ctx context.Context, return nil, fmt.Errorf("fetching Trakt list %s/%s: %w", user, list, err) } - slog.Info("Trakt list sync: fetched results", + slog.InfoContext(ctx, "Trakt list sync: fetched results", "component", "catalog", "collection_id", collection.ID, "user", user, "list", list, @@ -1083,7 +1083,7 @@ func (s *LibraryCollectionService) completeTraktEntrySync(ctx context.Context, c if firstRank, exists := seenContentIDs[item.ContentID]; exists { duplicateCount++ warnings = append(warnings, fmt.Sprintf("Duplicate Trakt entry for %s matched existing item %s", entry.Title, item.ContentID)) - slog.Info("Trakt preset sync: duplicate match skipped", + slog.InfoContext(ctx, "Trakt preset sync: duplicate match skipped", "component", "catalog", "rank", i+1, "title", entry.Title, "content_id", item.ContentID, @@ -1368,7 +1368,7 @@ func (s *LibraryCollectionService) maybeGenerateCollage(ctx context.Context, col collection, err := s.collections.GetByID(ctx, collectionID) if err != nil { - slog.Warn("collage: failed to load collection", "collection_id", collectionID, "error", err) + slog.WarnContext(ctx, "collage: failed to load collection", "component", "catalog", "collection_id", collectionID, "error", err) return } @@ -1379,9 +1379,9 @@ func (s *LibraryCollectionService) maybeGenerateCollage(ctx context.Context, col if err := s.CollageGen.GenerateCollectionPoster(ctx, collectionID); err != nil { if errors.Is(err, collage.ErrNotEnoughImages) { - slog.Debug("collage: not enough images", "collection_id", collectionID) + slog.DebugContext(ctx, "collage: not enough images", "component", "catalog", "collection_id", collectionID) } else { - slog.Warn("collage: poster generation failed", "collection_id", collectionID, "error", err) + slog.WarnContext(ctx, "collage: poster generation failed", "component", "catalog", "collection_id", collectionID, "error", err) } } } diff --git a/internal/catalog/person_repo.go b/internal/catalog/person_repo.go index dbd46ece..2efadd57 100644 --- a/internal/catalog/person_repo.go +++ b/internal/catalog/person_repo.go @@ -754,7 +754,7 @@ func (r *PersonRepository) resolveExternalIDConflict( // Writing the row's own current value back can never violate the unique // index, so this field will not re-trigger the conflict. setExternalIDField(p, field, externalIDValue(people[p.ID], field)) - slog.Warn("person merge: declining to merge, preserving survivor's existing id", + slog.WarnContext(ctx, "person merge: declining to merge, preserving survivor's existing id", "component", "catalog", "person_id", p.ID, "partner_id", partnerID, "field", field, "value", value, "person_name", p.Name, "partner_name", partner.Name) return true, nil @@ -784,7 +784,7 @@ func (r *PersonRepository) resolveExternalIDConflict( return false, fmt.Errorf("delete merged person %d: %w", partnerID, err) } - slog.Info("person merge: folded duplicate person", + slog.InfoContext(ctx, "person merge: folded duplicate person", "component", "catalog", "survivor_id", p.ID, "merged_id", partnerID, "field", field, "value", value) return true, nil } diff --git a/internal/catalog/search_indexer.go b/internal/catalog/search_indexer.go index bf564f64..ced32824 100644 --- a/internal/catalog/search_indexer.go +++ b/internal/catalog/search_indexer.go @@ -351,7 +351,7 @@ func (i *CatalogSearchIndexer) Rebuild(ctx context.Context, progress SearchIndex if err != nil { // The new index is already active; a failed cleanup costs disk on the // Meilisearch instance, not correctness, and the next rebuild retries. - slog.Warn("catalog search: failed to remove superseded meilisearch indexes", "err", err, "removed", removed) + slog.WarnContext(ctx, "catalog search: failed to remove superseded meilisearch indexes", "component", "catalog", "err", err, "removed", removed) } setSearchIndexTaskResult(progress, stats) reportSearchIndexProgress(progress, 100, fmt.Sprintf("Rebuilt catalog search index with %d documents", docCount)) diff --git a/internal/catalog/search_service.go b/internal/catalog/search_service.go index 8eed7be9..1525c5ad 100644 --- a/internal/catalog/search_service.go +++ b/internal/catalog/search_service.go @@ -24,7 +24,7 @@ func NewCatalogSearchService( ) *CatalogSearchService { settings, err := LoadCatalogSearchSettings(ctx, settingsStore) if err != nil { - slog.Warn("catalog search: failed to load settings; using postgres", "err", err) + slog.WarnContext(ctx, "catalog search: failed to load settings; using postgres", "component", "catalog", "err", err) settings = DefaultCatalogSearchSettings() } return NewCatalogSearchServiceFromSettings(settings, itemRepo, stateRepo, vectorizer...) diff --git a/internal/catalog/semantic_coverage.go b/internal/catalog/semantic_coverage.go index 09683e85..508a4537 100644 --- a/internal/catalog/semantic_coverage.go +++ b/internal/catalog/semantic_coverage.go @@ -275,7 +275,7 @@ func (t *semanticCoverageTracker) Refresh(ctx context.Context) error { m, err := t.models.ActiveEmbeddingModel(ctx) if err != nil { // Retain last-good: do not publish on a provider error. - slog.Warn("catalog semantic coverage: active model lookup failed; retaining last snapshot", "err", err) + slog.WarnContext(ctx, "catalog semantic coverage: active model lookup failed; retaining last snapshot", "component", "catalog", "err", err) return err } model = m @@ -307,7 +307,7 @@ func (t *semanticCoverageTracker) Refresh(ctx context.Context) error { types, err := t.fetch(ctx, model) if err != nil { // Retain last-good: do not overwrite a healthy snapshot with zeros. - slog.Warn("catalog semantic coverage: count query failed; retaining last snapshot", "err", err) + slog.WarnContext(ctx, "catalog semantic coverage: count query failed; retaining last snapshot", "component", "catalog", "err", err) return err } @@ -364,7 +364,7 @@ func (t *semanticCoverageTracker) Snapshot() *semanticCoverageSnapshot { // the loop (the last-good snapshot is retained by Refresh). func (t *semanticCoverageTracker) Run(ctx context.Context) { if err := t.Refresh(ctx); err != nil { - slog.Warn("catalog semantic coverage: initial refresh failed", "err", err) + slog.WarnContext(ctx, "catalog semantic coverage: initial refresh failed", "component", "catalog", "err", err) } ticker := time.NewTicker(semanticCoverageRefreshInterval) defer ticker.Stop() @@ -374,7 +374,7 @@ func (t *semanticCoverageTracker) Run(ctx context.Context) { return case <-ticker.C: if err := t.Refresh(ctx); err != nil { - slog.Warn("catalog semantic coverage: refresh failed", "err", err) + slog.WarnContext(ctx, "catalog semantic coverage: refresh failed", "component", "catalog", "err", err) } } } diff --git a/internal/catalog/smart_count_refresher.go b/internal/catalog/smart_count_refresher.go index 3d3d383a..88d8294b 100644 --- a/internal/catalog/smart_count_refresher.go +++ b/internal/catalog/smart_count_refresher.go @@ -25,7 +25,7 @@ func (r *SmartCountRefresher) RefreshAll(ctx context.Context) (refreshed int, er WHERE collection_type = 'smart' `) if err != nil { - slog.Warn("smart-count refresher: list query failed", "error", err) + slog.WarnContext(ctx, "smart-count refresher: list query failed", "component", "catalog", "error", err) return 0, 1 } defer rows.Close() @@ -38,19 +38,19 @@ func (r *SmartCountRefresher) RefreshAll(ctx context.Context) (refreshed int, er for rows.Next() { var e entry if err := rows.Scan(&e.id, &e.def); err != nil { - slog.Warn("smart-count refresher: scan failed", "error", err) + slog.WarnContext(ctx, "smart-count refresher: scan failed", "component", "catalog", "error", err) return refreshed, errs + 1 } batch = append(batch, e) } if err := rows.Err(); err != nil { - slog.Warn("smart-count refresher: row iter failed", "error", err) + slog.WarnContext(ctx, "smart-count refresher: row iter failed", "component", "catalog", "error", err) return refreshed, errs + 1 } for _, e := range batch { if err := r.refreshOne(ctx, e.id, e.def); err != nil { - slog.Warn("smart-count refresher: collection failed", "collection_id", e.id, "error", err) + slog.WarnContext(ctx, "smart-count refresher: collection failed", "component", "catalog", "collection_id", e.id, "error", err) errs++ continue } diff --git a/internal/chapterthumbs/service.go b/internal/chapterthumbs/service.go index 8f00cbfc..4022812d 100644 --- a/internal/chapterthumbs/service.go +++ b/internal/chapterthumbs/service.go @@ -204,8 +204,8 @@ func (s *Service) Start(ctx context.Context) { } resolvedAccel, resolvedDevice := s.resolveHWConfig() - slog.Info( - "chapter thumbnail service started", + slog.InfoContext(ctx, + "chapter thumbnail service started", "component", "chapterthumbs", "workers", s.workerCount, "priority_workers", @@ -280,7 +280,7 @@ func (s *Service) BackfillMissing(ctx context.Context, limit int) (int, error) { continue } if _, err := s.processRequest(ctx, ChapterThumbnailRequest{FileID: file.ID}, false); err != nil { - slog.Warn("chapter thumbnail backfill failed", "file_id", file.ID, "error", err) + slog.WarnContext(ctx, "chapter thumbnail backfill failed", "component", "chapterthumbs", "file_id", file.ID, "error", err) continue } processed++ @@ -296,7 +296,7 @@ func (s *Service) worker(ctx context.Context, priorityOnly bool) { } requeueNormal, err := s.processRequest(ctx, req, priorityOnly) if err != nil { - slog.Warn("chapter thumbnail generation failed", "file_id", req.FileID, "error", err) + slog.WarnContext(ctx, "chapter thumbnail generation failed", "component", "chapterthumbs", "file_id", req.FileID, "error", err) } notifyPriority, notifyNormal := s.finishProcessing(req.FileID) if notifyPriority { @@ -315,7 +315,7 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques file, err := s.fileRepo.GetByID(ctx, req.FileID) if err != nil || file == nil { if err == nil { - slog.Info("chapter thumbnail request skipped", "file_id", req.FileID, "priority", priority, "reason", "file_not_found") + slog.InfoContext(ctx, "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", priority, "reason", "file_not_found") } return false, err } @@ -323,13 +323,13 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques folder, err := s.folderRepo.GetByID(ctx, file.MediaFolderID) if err != nil || folder == nil { if err == nil { - slog.Info("chapter thumbnail request skipped", "file_id", req.FileID, "priority", priority, "reason", "folder_not_found") + slog.InfoContext(ctx, "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", priority, "reason", "folder_not_found") } return false, err } if !folder.Enabled || !folder.ChapterThumbnailsEnabled { - slog.Info( - "chapter thumbnail request skipped", + slog.InfoContext(ctx, + "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", @@ -344,8 +344,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques now := s.now() if file.ChapterThumbnailRetryAfter != nil && file.ChapterThumbnailRetryAfter.After(now) { - slog.Info( - "chapter thumbnail request skipped", + slog.InfoContext(ctx, + "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", @@ -363,14 +363,14 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques return false, err } if len(file.Chapters) == 0 { - slog.Info("chapter thumbnail request skipped", "file_id", req.FileID, "priority", priority, "reason", "no_chapters") + slog.InfoContext(ctx, "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", priority, "reason", "no_chapters") return false, nil } hdrPolicy := s.chapterThumbnailHDRPolicy(ctx) if needsTonemap(file) && hdrPolicy == chapterThumbnailHDRPolicyDisabled { - slog.Info( - "chapter thumbnail request skipped", + slog.InfoContext(ctx, + "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", @@ -383,8 +383,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques selected := selectChapterCandidates(file.Chapters, req.TargetSeconds, priority, s.batchSize(priority), now) if len(selected) == 0 { - slog.Info( - "chapter thumbnail request skipped", + slog.InfoContext(ctx, + "chapter thumbnail request skipped", "component", "chapterthumbs", "file_id", req.FileID, "priority", @@ -395,8 +395,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques return false, nil } - slog.Info( - "chapter thumbnail processing started", + slog.InfoContext(ctx, + "chapter thumbnail processing started", "component", "chapterthumbs", "file_id", req.FileID, "priority", @@ -422,8 +422,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques chapter := updated.Chapters[candidate.offset] frame, reason, err := s.extractFrame(ctx, &updated, chapterCaptureTime(chapter), hdrPolicy) if err != nil { - slog.Warn( - "chapter thumbnail extract failed", + slog.WarnContext(ctx, + "chapter thumbnail extract failed", "component", "chapterthumbs", "file_id", updated.ID, "chapter_index", @@ -438,8 +438,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques failed++ if shouldApplyFileFailure(reason) { hardFileFailure = buildPersistentFileFailure(&updated, now, reason, err) - slog.Warn( - "chapter thumbnail file marked failed", + slog.WarnContext(ctx, + "chapter thumbnail file marked failed", "component", "chapterthumbs", "file_id", updated.ID, "reason", @@ -454,8 +454,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques path, thumbhash, err := s.uploadChapterThumbnail(ctx, updated.ID, chapter.Index, frame) if err != nil { - slog.Warn( - "chapter thumbnail upload failed", + slog.WarnContext(ctx, + "chapter thumbnail upload failed", "component", "chapterthumbs", "file_id", updated.ID, "chapter_index", @@ -519,8 +519,8 @@ func (s *Service) processRequest(ctx context.Context, req ChapterThumbnailReques } requeue := hardFileFailure == nil && hasEligibleMissingChapter(updated.Chapters, now) - slog.Info( - "chapter thumbnail processing finished", + slog.InfoContext(ctx, + "chapter thumbnail processing finished", "component", "chapterthumbs", "file_id", req.FileID, "priority", @@ -547,7 +547,7 @@ func (s *Service) ensureChapters(ctx context.Context, file *models.MediaFile, no } reason := classifyProbeError(err) - slog.Warn("chapter thumbnail probe failed", "file_id", file.ID, "reason", reason, "error", err) + slog.WarnContext(ctx, "chapter thumbnail probe failed", "component", "chapterthumbs", "file_id", file.ID, "reason", reason, "error", err) if applyErr := s.applyFileFailure(ctx, file, now, reason, err); applyErr != nil { return file, applyErr } @@ -596,8 +596,8 @@ func (s *Service) extractFrame( node, release, nodeReason := s.reserveRemoteNode(ctx) if node == nil { if mode == chapterThumbnailExecutionPreferTranscode { - slog.Info( - "chapter thumbnail remote execution unavailable; falling back to local", + slog.InfoContext(ctx, + "chapter thumbnail remote execution unavailable; falling back to local", "component", "chapterthumbs", "file_path", file.FilePath, "reason", @@ -620,8 +620,8 @@ func (s *Service) extractFrame( } if mode == chapterThumbnailExecutionPreferTranscode && isInfrastructureRemoteFailure(reason) { - slog.Warn( - "chapter thumbnail remote extraction failed; falling back to local", + slog.WarnContext(ctx, + "chapter thumbnail remote extraction failed; falling back to local", "component", "chapterthumbs", "file_path", file.FilePath, "node", @@ -888,8 +888,8 @@ func (s *Service) nextRequest(ctx context.Context, priorityOnly bool) (ChapterTh normalDepth := len(s.queuedNormal) inProgress := len(s.inProgress) s.mu.Unlock() - slog.Info( - "chapter thumbnail dequeued", + slog.InfoContext(ctx, + "chapter thumbnail dequeued", "component", "chapterthumbs", "file_id", req.FileID, "priority", @@ -913,8 +913,8 @@ func (s *Service) nextRequest(ctx context.Context, priorityOnly bool) (ChapterTh normalDepth := len(s.queuedNormal) inProgress := len(s.inProgress) s.mu.Unlock() - slog.Info( - "chapter thumbnail dequeued", + slog.InfoContext(ctx, + "chapter thumbnail dequeued", "component", "chapterthumbs", "file_id", req.FileID, "priority", diff --git a/internal/downloads/artifacts.go b/internal/downloads/artifacts.go index 5688e57b..179dc817 100644 --- a/internal/downloads/artifacts.go +++ b/internal/downloads/artifacts.go @@ -229,7 +229,7 @@ func (m *ArtifactManager) RunOnce(ctx context.Context) error { // repeatedly (each step is idempotent). func (m *ArtifactManager) recover(ctx context.Context) { if _, err := m.repo.ReclaimExpiredLeases(ctx); err != nil { - slog.Warn("download artifact lease reclaim failed", "error", err) + slog.WarnContext(ctx, "download artifact lease reclaim failed", "component", "downloads", "error", err) } // Reconcile downloads stranded in 'preparing' against their artifact's @@ -239,7 +239,7 @@ func (m *ArtifactManager) recover(ctx context.Context) { // failed above) so a download can never sit 'preparing' forever. readyFlipped, failedFlipped, err := m.downloads.ReconcileLinkedDownloads(ctx) if err != nil { - slog.Warn("reconciling linked downloads failed", "error", err) + slog.WarnContext(ctx, "reconciling linked downloads failed", "component", "downloads", "error", err) } else { for _, d := range readyFlipped { m.publish(ctx, d) @@ -256,7 +256,7 @@ func (m *ArtifactManager) recover(ctx context.Context) { } ready, err := m.repo.ListReady(ctx) if err != nil { - slog.Warn("download artifact ready scan failed", "error", err) + slog.WarnContext(ctx, "download artifact ready scan failed", "component", "downloads", "error", err) return } for _, a := range ready { @@ -264,9 +264,9 @@ func (m *ArtifactManager) recover(ctx context.Context) { continue } if _, statErr := os.Stat(a.OutputPath); statErr != nil { - slog.Warn("download artifact output missing, re-queuing", "artifact_id", a.ID, "path", a.OutputPath) + slog.WarnContext(ctx, "download artifact output missing, re-queuing", "component", "downloads", "artifact_id", a.ID, "path", a.OutputPath) if err := m.repo.Requeue(ctx, a.ID); err != nil { - slog.Warn("re-queue artifact failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "re-queue artifact failed", "component", "downloads", "artifact_id", a.ID, "error", err) continue } m.triggerDrain() @@ -340,10 +340,10 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) { return case hbCtx.Err() != nil: // We lost the lease mid-encode; another worker now owns the job. - slog.Warn("download artifact encode aborted; lease lost", "artifact_id", a.ID) + slog.WarnContext(ctx, "download artifact encode aborted; lease lost", "component", "downloads", "artifact_id", a.ID) return default: - slog.Warn("download artifact encode failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "download artifact encode failed", "component", "downloads", "artifact_id", a.ID, "error", err) m.failJob(ctx, a, err.Error()) return } @@ -358,16 +358,16 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) { // do not flip them here or we would race/duplicate that owner's work. applied, err := m.repo.MarkReady(ctx, a.ID, m.owner, a.OutputPath, size) if err != nil { - slog.Error("marking artifact ready failed", "artifact_id", a.ID, "error", err) + slog.ErrorContext(ctx, "marking artifact ready failed", "component", "downloads", "artifact_id", a.ID, "error", err) return } if !applied { - slog.Warn("download artifact ready skipped; lease lost", "artifact_id", a.ID) + slog.WarnContext(ctx, "download artifact ready skipped; lease lost", "component", "downloads", "artifact_id", a.ID) return } flipped, err := m.downloads.MarkLinkedDownloadsReady(ctx, a.ID, size) if err != nil { - slog.Error("flipping linked downloads ready failed", "artifact_id", a.ID, "error", err) + slog.ErrorContext(ctx, "flipping linked downloads ready failed", "component", "downloads", "artifact_id", a.ID, "error", err) return } for _, d := range flipped { @@ -378,7 +378,7 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) { func (m *ArtifactManager) failJob(ctx context.Context, a *Artifact, msg string) { terminal, applied, err := m.repo.MarkFailedOrRetry(ctx, a.ID, m.owner, msg, backoffFor(a.Attempts)) if err != nil { - slog.Error("marking artifact failed/retry errored", "artifact_id", a.ID, "error", err) + slog.ErrorContext(ctx, "marking artifact failed/retry errored", "component", "downloads", "artifact_id", a.ID, "error", err) return } if !applied { @@ -395,7 +395,7 @@ func (m *ArtifactManager) failJob(ctx context.Context, a *Artifact, msg string) func (m *ArtifactManager) failLinkedDownloads(ctx context.Context, artifactID, msg string) { flipped, err := m.downloads.MarkLinkedDownloadsFailed(ctx, artifactID, msg) if err != nil { - slog.Error("flipping linked downloads failed errored", "artifact_id", artifactID, "error", err) + slog.ErrorContext(ctx, "flipping linked downloads failed errored", "component", "downloads", "artifact_id", artifactID, "error", err) return } for _, d := range flipped { @@ -426,9 +426,9 @@ func (m *ArtifactManager) heartbeatLoop(ctx context.Context, cancel context.Canc case err != nil && ctx.Err() != nil: return // encode finished or shutting down case err != nil: - slog.Warn("download artifact heartbeat errored", "artifact_id", id, "error", err) + slog.WarnContext(ctx, "download artifact heartbeat errored", "component", "downloads", "artifact_id", id, "error", err) case !ok: - slog.Warn("download artifact lease lost; aborting encode", "artifact_id", id) + slog.WarnContext(ctx, "download artifact lease lost; aborting encode", "component", "downloads", "artifact_id", id) cancel() return } @@ -498,7 +498,7 @@ func (m *ArtifactManager) Cleanup(ctx context.Context) error { } active, err := m.repo.HasActiveLink(ctx, a.ID) if err != nil { - slog.Warn("artifact link check failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "artifact link check failed", "component", "downloads", "artifact_id", a.ID, "error", err) continue } if active { @@ -506,14 +506,14 @@ func (m *ArtifactManager) Cleanup(ctx context.Context) error { } if a.OutputPath != "" { if err := os.Remove(a.OutputPath); err != nil && !os.IsNotExist(err) { - slog.Warn("removing evicted artifact file failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "removing evicted artifact file failed", "component", "downloads", "artifact_id", a.ID, "error", err) } } if err := m.repo.DeleteArtifact(ctx, a.ID); err != nil { - slog.Warn("deleting evicted artifact row failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "deleting evicted artifact row failed", "component", "downloads", "artifact_id", a.ID, "error", err) continue } - slog.Info("evicted download artifact (LRU)", "artifact_id", a.ID, "bytes", a.FileSize) + slog.InfoContext(ctx, "evicted download artifact (LRU)", "component", "downloads", "artifact_id", a.ID, "bytes", a.FileSize) total -= a.FileSize } return nil @@ -529,14 +529,14 @@ func (m *ArtifactManager) sweepStale(ctx context.Context) { } now := time.Now() if failed, err := m.repo.ListFailedBefore(ctx, now.Add(-failedArtifactRetention)); err != nil { - slog.Warn("failed-artifact sweep list failed", "error", err) + slog.WarnContext(ctx, "failed-artifact sweep list failed", "component", "downloads", "error", err) } else { for _, a := range failed { m.removeArtifact(ctx, a, "failed") } } if orphans, err := m.repo.ListUnlinkedReadyBefore(ctx, now.Add(-unlinkedArtifactRetention)); err != nil { - slog.Warn("unlinked-artifact sweep list failed", "error", err) + slog.WarnContext(ctx, "unlinked-artifact sweep list failed", "component", "downloads", "error", err) } else { for _, a := range orphans { m.removeArtifact(ctx, a, "unlinked") @@ -544,9 +544,9 @@ func (m *ArtifactManager) sweepStale(ctx context.Context) { } if m.downloads != nil { if n, err := m.downloads.PruneEphemeralOlderThan(ctx, now.Add(-ephemeralDownloadRetention)); err != nil { - slog.Warn("ephemeral download prune failed", "error", err) + slog.WarnContext(ctx, "ephemeral download prune failed", "component", "downloads", "error", err) } else if n > 0 { - slog.Info("pruned expired ephemeral downloads", "rows", n) + slog.InfoContext(ctx, "pruned expired ephemeral downloads", "component", "downloads", "rows", n) } } } @@ -556,17 +556,17 @@ func (m *ArtifactManager) sweepStale(ctx context.Context) { func (m *ArtifactManager) removeArtifact(ctx context.Context, a *Artifact, reason string) { if a.OutputPath != "" { if err := os.Remove(a.OutputPath); err != nil && !os.IsNotExist(err) { - slog.Warn("removing swept artifact file failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "removing swept artifact file failed", "component", "downloads", "artifact_id", a.ID, "error", err) } if err := os.Remove(a.OutputPath + ".part"); err != nil && !os.IsNotExist(err) { - slog.Warn("removing swept artifact partial failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "removing swept artifact partial failed", "component", "downloads", "artifact_id", a.ID, "error", err) } } if err := m.repo.DeleteArtifact(ctx, a.ID); err != nil { - slog.Warn("deleting swept artifact row failed", "artifact_id", a.ID, "error", err) + slog.WarnContext(ctx, "deleting swept artifact row failed", "component", "downloads", "artifact_id", a.ID, "error", err) return } - slog.Info("swept stale download artifact", "artifact_id", a.ID, "reason", reason, "bytes", a.FileSize) + slog.InfoContext(ctx, "swept stale download artifact", "component", "downloads", "artifact_id", a.ID, "reason", reason, "bytes", a.FileSize) } // backoffFor returns the retry delay for the next attempt after a failure. diff --git a/internal/downloads/offline.go b/internal/downloads/offline.go index 3b35dc26..28b8c58c 100644 --- a/internal/downloads/offline.go +++ b/internal/downloads/offline.go @@ -92,7 +92,7 @@ func (s *Service) BuildBatchManifests(ctx context.Context, userID int, profileID if errors.Is(err, catalog.ErrItemNotFound) { reason = "not_found" } else { - slog.Warn("batch manifest build failed", "download_id", dl.ID, "batch_id", batchID, "error", err) + slog.WarnContext(ctx, "batch manifest build failed", "component", "downloads", "download_id", dl.ID, "batch_id", batchID, "error", err) } skipped = append(skipped, SkippedManifest{DownloadID: dl.ID, Reason: reason}) continue diff --git a/internal/downloads/service.go b/internal/downloads/service.go index 10e1b890..b10957b0 100644 --- a/internal/downloads/service.go +++ b/internal/downloads/service.go @@ -209,13 +209,13 @@ func (s *Service) loadConfig(ctx context.Context) config.DownloadConfig { allSettings, err := s.settings.GetAll(ctx) if err != nil { - slog.Warn("failed to reload download config from DB, using cached", "error", err) + slog.WarnContext(ctx, "failed to reload download config from DB, using cached", "component", "downloads", "error", err) return s.cfg } newFullCfg, err := config.LoadFromDB(allSettings) if err != nil { - slog.Warn("failed to parse download config from DB, using cached", "error", err) + slog.WarnContext(ctx, "failed to parse download config from DB, using cached", "component", "downloads", "error", err) return s.cfg } @@ -226,13 +226,13 @@ func (s *Service) loadConfig(ctx context.Context) config.DownloadConfig { // Update bandwidth manager if limits changed. if s.bandwidth != nil && (oldCfg.ServerBandwidthBPS != s.cfg.ServerBandwidthBPS || oldCfg.UserBandwidthBPS != s.cfg.UserBandwidthBPS) { s.bandwidth.Reload(s.cfg.ServerBandwidthBPS, s.cfg.UserBandwidthBPS) - slog.Info("download bandwidth config reloaded", "server_bps", s.cfg.ServerBandwidthBPS, "user_bps", s.cfg.UserBandwidthBPS) + slog.InfoContext(ctx, "download bandwidth config reloaded", "component", "downloads", "server_bps", s.cfg.ServerBandwidthBPS, "user_bps", s.cfg.UserBandwidthBPS) } // Update quantity limiter if limits changed. if s.limiter != nil && (oldCfg.MaxConcurrentPerUser != s.cfg.MaxConcurrentPerUser || oldCfg.MaxPerPeriod != s.cfg.MaxPerPeriod || oldCfg.PeriodDuration != s.cfg.PeriodDuration) { s.limiter.Reload(s.cfg.MaxConcurrentPerUser, s.cfg.MaxPerPeriod, s.cfg.PeriodDuration) - slog.Info("download quantity limits reloaded", "max_concurrent", s.cfg.MaxConcurrentPerUser, "max_per_period", s.cfg.MaxPerPeriod, "period", s.cfg.PeriodDuration) + slog.InfoContext(ctx, "download quantity limits reloaded", "component", "downloads", "max_concurrent", s.cfg.MaxConcurrentPerUser, "max_per_period", s.cfg.MaxPerPeriod, "period", s.cfg.PeriodDuration) } return s.cfg @@ -905,14 +905,14 @@ func (s *Service) ServeFile(ctx context.Context, w http.ResponseWriter, r *http. if errors.Is(err, ErrStatusConflict) { return fmt.Errorf("download already in progress: %w", ErrDownloadNotActive) } - slog.Warn("failed to transition download to downloading", "download_id", dl.ID, "error", err) + slog.WarnContext(ctx, "failed to transition download to downloading", "component", "downloads", "download_id", dl.ID, "error", err) } } if err := s.serveDownloadBytes(ctx, w, r, dl, userID, filter); err != nil { if dl.Format == FormatOriginal { if updateErr := s.repo.UpdateStatus(ctx, dl.ID, StatusFailed, 0, nil); updateErr != nil { - slog.Error("failed to mark download as failed", "download_id", dl.ID, "error", updateErr) + slog.ErrorContext(ctx, "failed to mark download as failed", "component", "downloads", "download_id", dl.ID, "error", updateErr) } } return err @@ -920,7 +920,7 @@ func (s *Service) ServeFile(ctx context.Context, w http.ResponseWriter, r *http. now := time.Now() if err := s.repo.UpdateStatus(ctx, dl.ID, StatusCompleted, dl.FileSize, &now); err != nil { - slog.Error("failed to mark download as completed", "download_id", dl.ID, "error", err) + slog.ErrorContext(ctx, "failed to mark download as completed", "component", "downloads", "download_id", dl.ID, "error", err) } return nil } diff --git a/internal/downloads/subscription_service.go b/internal/downloads/subscription_service.go index ecb83656..4a4b9f98 100644 --- a/internal/downloads/subscription_service.go +++ b/internal/downloads/subscription_service.go @@ -93,7 +93,7 @@ func (s *Service) CreateSubscription(ctx context.Context, userID int, req Subscr // be retried by syncing or re-monitoring. So log it rather than failing. registered, err := s.syncSubscription(ctx, stored) if err != nil { - slog.Warn("download subscription initial sync failed", "subscription_id", stored.ID, "error", err) + slog.WarnContext(ctx, "download subscription initial sync failed", "component", "downloads", "subscription_id", stored.ID, "error", err) } return &SubscriptionResult{Subscription: stored, Registered: registered}, nil } @@ -200,7 +200,7 @@ func (s *Service) UpdateSubscription(ctx context.Context, userID int, profileID, } registered, err := s.syncSubscription(ctx, sub) if err != nil { - slog.Warn("download subscription update sync failed", "subscription_id", sub.ID, "error", err) + slog.WarnContext(ctx, "download subscription update sync failed", "component", "downloads", "subscription_id", sub.ID, "error", err) } return &SubscriptionResult{Subscription: sub, Registered: registered}, nil } @@ -250,7 +250,7 @@ func (s *Service) SyncSubscriptions(ctx context.Context, userID int, profileID, } n, err := s.syncSubscription(ctx, sub) if err != nil { - slog.Warn("download subscription sync failed", "subscription_id", sub.ID, "error", err) + slog.WarnContext(ctx, "download subscription sync failed", "component", "downloads", "subscription_id", sub.ID, "error", err) continue } total += n @@ -294,7 +294,7 @@ func (s *Service) capItemsToStorage(ctx context.Context, sub *Subscription, item } used, err := s.repo.SumManagedFileSize(ctx, sub.UserID, sub.ProfileID, sub.DeviceID) if err != nil { - slog.Warn("download subscription storage gate: sum failed; skipping cap", "subscription_id", sub.ID, "error", err) + slog.WarnContext(ctx, "download subscription storage gate: sum failed; skipping cap", "component", "downloads", "subscription_id", sub.ID, "error", err) return items } kept := make([]managedItem, 0, len(items)) diff --git a/internal/ebooks/enrichment.go b/internal/ebooks/enrichment.go index d08ac03c..a9a387a9 100644 --- a/internal/ebooks/enrichment.go +++ b/internal/ebooks/enrichment.go @@ -145,14 +145,14 @@ func (e *Enricher) Run(ctx context.Context) (int, error) { return 0, nil } - slog.Info("ebook enrichment: sweep started", + slog.InfoContext(ctx, "ebook enrichment: sweep started", "component", "ebooks", "count", len(items), "workers", e.workers, ) enriched := e.runBatch(ctx, items, e.enrichItem, e.recordEnrichFailure) - slog.Info("ebook enrichment: sweep complete", + slog.InfoContext(ctx, "ebook enrichment: sweep complete", "component", "ebooks", "attempted", len(items), "enriched", enriched, ) @@ -188,14 +188,14 @@ func (e *Enricher) runBatch( } if err := enrichFn(ctx, item); err != nil { if errors.Is(err, errEnrichmentSkipped) { - slog.Debug("ebook enrichment: item skipped", + slog.DebugContext(ctx, "ebook enrichment: item skipped", "component", "ebooks", "content_id", item.ContentID, "title", item.Title, "reason", err, ) continue } - slog.Warn("ebook enrichment: item failed", + slog.WarnContext(ctx, "ebook enrichment: item failed", "component", "ebooks", "content_id", item.ContentID, "title", item.Title, "error", err, @@ -348,7 +348,7 @@ func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemR return fmt.Errorf("no metadata obtained, %d provider error(s): %w", len(providerErrs), errors.Join(providerErrs...)) } - slog.Info("ebook enrichment: no metadata found", + slog.InfoContext(ctx, "ebook enrichment: no metadata found", "component", "ebooks", "content_id", item.ContentID, "title", item.Title, ) @@ -361,7 +361,7 @@ func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemR e.enqueueRemoteArtwork(ctx, item.ContentID, accumulator) e.autoLinkLiteraryWork(ctx, item.ContentID) - slog.Info("ebook enrichment: enriched", + slog.InfoContext(ctx, "ebook enrichment: enriched", "component", "ebooks", "content_id", item.ContentID, "title", item.Title, "poster", accumulator.PosterPath != "", @@ -397,7 +397,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers } results, searchErr := sp.Search(ctx, searchQuery) if searchErr != nil { - slog.Warn("ebook enrichment: search error", + slog.WarnContext(ctx, "ebook enrichment: search error", "component", "ebooks", "provider", p.Slug(), "content_id", item.ContentID, "error", searchErr, @@ -425,7 +425,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers continue } if ownerID != "" { - slog.Info("ebook enrichment: provider id already owned by another item; skipping match", + slog.InfoContext(ctx, "ebook enrichment: provider id already owned by another item; skipping match", "component", "ebooks", "provider", k, "provider_id", v, "content_id", item.ContentID, @@ -436,7 +436,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers } accumulatedIDs[k] = v } - slog.Debug("ebook enrichment: search result", + slog.DebugContext(ctx, "ebook enrichment: search result", "component", "ebooks", "provider", p.Slug(), "content_id", item.ContentID, "matched_ids", accumulatedIDs, @@ -454,7 +454,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers } result, getErr := mp.GetMetadata(ctx, buildEbookMetadataRequest(accumulator.ProviderIDs, item.Language)) if getErr != nil { - slog.Warn("ebook enrichment: GetMetadata error", + slog.WarnContext(ctx, "ebook enrichment: GetMetadata error", "component", "ebooks", "provider", p.Slug(), "content_id", item.ContentID, "error", getErr, @@ -468,7 +468,7 @@ func collectEbookMetadata(ctx context.Context, item enrichmentItemRow, providers mergeEnrichmentProviderIDs(accumulator, result) metadata.MergeMetadata(result, accumulator, nil, metadata.MergeFillEmpty) - slog.Debug("ebook enrichment: metadata received", + slog.DebugContext(ctx, "ebook enrichment: metadata received", "component", "ebooks", "provider", p.Slug(), "content_id", item.ContentID, "has_poster", result.PosterPath != "", @@ -485,11 +485,11 @@ func (e *Enricher) autoLinkLiteraryWork(ctx context.Context, contentID string) { } workID, linked, err := e.workLinker.AutoLinkContent(ctx, contentID) if err != nil { - slog.Warn("ebook enrichment: literary work auto-link failed", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "ebook enrichment: literary work auto-link failed", "component", "ebooks", "content_id", contentID, "error", err) return } if linked { - slog.Info("ebook enrichment: literary work auto-linked", "content_id", contentID, "work_id", workID) + slog.InfoContext(ctx, "ebook enrichment: literary work auto-linked", "component", "ebooks", "content_id", contentID, "work_id", workID) } } @@ -512,7 +512,7 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu ImageType: metadata.ImagePoster, }) if err != nil { - slog.Warn("ebook enrichment: poster cache failed, keeping provider URL", + slog.WarnContext(ctx, "ebook enrichment: poster cache failed, keeping provider URL", "component", "ebooks", "content_id", contentID, "url", result.PosterPath, "error", err, @@ -520,7 +520,7 @@ func (e *Enricher) cacheRemotePoster(ctx context.Context, contentID string, resu return } if cached == nil { - slog.Warn("ebook enrichment: poster cache returned no result, keeping provider URL", + slog.WarnContext(ctx, "ebook enrichment: poster cache returned no result, keeping provider URL", "component", "ebooks", "content_id", contentID, "url", result.PosterPath, ) @@ -564,7 +564,7 @@ func (e *Enricher) enqueueRemoteArtwork(ctx context.Context, contentID string, r enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() if _, err := e.imageCacheJobs.EnqueueBatch(enqueueCtx, inputs); err != nil { - slog.Warn("ebook enrichment: failed to enqueue image cache jobs", + slog.WarnContext(ctx, "ebook enrichment: failed to enqueue image cache jobs", "component", "ebooks", "content_id", contentID, "count", len(inputs), "error", err, @@ -659,7 +659,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma providerIDs = filterEbookProviderIDs(providerIDs) if e.providerIDs != nil && len(providerIDs) > 0 { if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil { - slog.Warn("ebook enrichment: failed to persist provider IDs", + slog.WarnContext(ctx, "ebook enrichment: failed to persist provider IDs", "component", "ebooks", "content_id", contentID, "error", err, ) @@ -673,7 +673,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma authors := filterEbookPeople(result.People) if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil { if err := e.persistPeople(ctx, contentID, authors); err != nil { - slog.Warn("ebook enrichment: failed to persist people", + slog.WarnContext(ctx, "ebook enrichment: failed to persist people", "component", "ebooks", "content_id", contentID, "error", err, ) @@ -731,7 +731,7 @@ func (e *Enricher) recordEnrichFailure(ctx context.Context, item enrichmentItemR failures = ebook_enrichment_state.failures + 1, updated_at = NOW() `, item.ContentID); err != nil { - slog.Warn("ebook enrichment: failed to record enrichment failure", + slog.WarnContext(ctx, "ebook enrichment: failed to record enrichment failure", "component", "ebooks", "content_id", item.ContentID, "error", err, ) diff --git a/internal/historyimport/plex_admin_provider.go b/internal/historyimport/plex_admin_provider.go index 3bef556a..79386862 100644 --- a/internal/historyimport/plex_admin_provider.go +++ b/internal/historyimport/plex_admin_provider.go @@ -81,7 +81,7 @@ func (p *PlexAdminProvider) fetchSeriesMetadata(ctx context.Context, items []Ple for _, key := range seriesKeys { meta, err := p.client.FetchMetadata(ctx, p.baseURL, p.token, key) if err != nil { - slog.Warn("plex admin history import: failed to fetch series metadata", "rating_key", key, "error", err) + slog.WarnContext(ctx, "plex admin history import: failed to fetch series metadata", "component", "historyimport", "rating_key", key, "error", err) *warnings = append(*warnings, fmt.Sprintf("failed to fetch series metadata for %s: %v", key, err)) continue } diff --git a/internal/historyimport/plex_provider.go b/internal/historyimport/plex_provider.go index 941fb315..ffcb7c4f 100644 --- a/internal/historyimport/plex_provider.go +++ b/internal/historyimport/plex_provider.go @@ -112,7 +112,7 @@ func (p *PlexServerProvider) fetchSeriesMetadata(ctx context.Context, items []Pl for _, key := range seriesKeys { meta, err := p.client.FetchMetadata(ctx, p.baseURL, p.token, key) if err != nil { - slog.Warn("plex history import: failed to fetch series metadata", "rating_key", key, "error", err) + slog.WarnContext(ctx, "plex history import: failed to fetch series metadata", "component", "historyimport", "rating_key", key, "error", err) *warnings = append(*warnings, fmt.Sprintf("failed to fetch series metadata for %s: %v", key, err)) continue } diff --git a/internal/historyimport/service.go b/internal/historyimport/service.go index 6d59df41..1e210588 100644 --- a/internal/historyimport/service.go +++ b/internal/historyimport/service.go @@ -600,12 +600,12 @@ func (s *Service) executeRun(run *Run, provider Provider) { func (s *Service) failRun(ctx context.Context, runID string, summary ExecutionSummary, err error) { message := userFacingRunError(summary, err) if updateErr := s.repo.FailRun(ctx, runID, summary, message); updateErr != nil { - slog.Error("history import: failed to mark run failed", "run_id", runID, "error", updateErr, "root_error", err) + slog.ErrorContext(ctx, "history import: failed to mark run failed", "component", "historyimport", "run_id", runID, "error", updateErr, "root_error", err) return } s.notifyRunByID(ctx, runID) - slog.Error( - "history import: failed", + slog.ErrorContext(ctx, + "history import: failed", "component", "historyimport", "run_id", runID, "error", message, "root_error", err, @@ -756,7 +756,7 @@ func toConnectServerResponses(servers []ConnectServer) []ConnectServerResponse { func (s *Service) persistProgress(ctx context.Context, runID string, summary ExecutionSummary) { if err := s.repo.UpdateRunProgress(ctx, runID, summary); err != nil && !errors.Is(err, ErrRunNotFound) { - slog.Warn("history import: failed to persist progress", "run_id", runID, "error", err) + slog.WarnContext(ctx, "history import: failed to persist progress", "component", "historyimport", "run_id", runID, "error", err) return } s.notifyRunByID(ctx, runID) @@ -765,8 +765,8 @@ func (s *Service) persistProgress(ctx context.Context, runID string, summary Exe func (s *Service) persistProgressMaybe(ctx context.Context, runID string, summary ExecutionSummary, processed, total int) { if processed == total || processed%25 == 0 { s.persistProgress(ctx, runID, summary) - slog.Info( - "history import: progress", + slog.InfoContext(ctx, + "history import: progress", "component", "historyimport", "run_id", runID, "processed", processed, "total", total, diff --git a/internal/historyimport/service_admin.go b/internal/historyimport/service_admin.go index f8fe2bf1..fb93c664 100644 --- a/internal/historyimport/service_admin.go +++ b/internal/historyimport/service_admin.go @@ -182,7 +182,7 @@ func (s *Service) BulkCreateAdminRuns(ctx context.Context, sourceID int) (*BulkR mappingID := mapping.ID provider, err := s.buildAdminProvider(source, token, mapping.ExternalUserID) if err != nil { - slog.Error("history import bulk: failed to build provider", "mapping_id", mappingID, "error", err) + slog.ErrorContext(ctx, "history import bulk: failed to build provider", "component", "historyimport", "mapping_id", mappingID, "error", err) result.Errors++ continue } @@ -200,7 +200,7 @@ func (s *Service) BulkCreateAdminRuns(ctx context.Context, sourceID int) (*BulkR } created, err := s.repo.CreateRun(ctx, run) if err != nil { - slog.Error("history import bulk: failed to create run", "mapping_id", mappingID, "error", err) + slog.ErrorContext(ctx, "history import bulk: failed to create run", "component", "historyimport", "mapping_id", mappingID, "error", err) result.Errors++ continue } diff --git a/internal/historyimport/service_maintenance.go b/internal/historyimport/service_maintenance.go index d500b2a9..d95657f8 100644 --- a/internal/historyimport/service_maintenance.go +++ b/internal/historyimport/service_maintenance.go @@ -40,7 +40,7 @@ func (s *Service) reconcileStaleRuns(ctx context.Context, now time.Time) error { return sweepStaleRunsOnce(ctx, now, historyImportStaleRunThreshold, func(ctx context.Context, staleBefore time.Time, message string) (int64, error) { count, err := s.repo.FailStaleRuns(ctx, staleBefore, message) if err == nil && count > 0 { - slog.Warn("history import: reconciled stale running runs", "count", count, "stale_before", staleBefore) + slog.WarnContext(ctx, "history import: reconciled stale running runs", "component", "historyimport", "count", count, "stale_before", staleBefore) } return count, err }) @@ -51,7 +51,7 @@ func (s *Service) startRunHeartbeat(ctx context.Context, runID string) context.C go heartbeatLoop(hbCtx, historyImportHeartbeatInterval, func(ctx context.Context) error { err := s.repo.TouchRunHeartbeat(ctx, runID) if err != nil && !errors.Is(err, ErrRunNotFound) { - slog.Warn("history import: failed to persist heartbeat", "run_id", runID, "error", err) + slog.WarnContext(ctx, "history import: failed to persist heartbeat", "component", "historyimport", "run_id", runID, "error", err) } return nil }) diff --git a/internal/intromarkers/analyzer.go b/internal/intromarkers/analyzer.go index 6e17b384..546d8e83 100644 --- a/internal/intromarkers/analyzer.go +++ b/internal/intromarkers/analyzer.go @@ -153,7 +153,7 @@ func (a *Analyzer) Run(ctx context.Context, progress ProgressFunc) (RunSummary, summary.GroupsSkipped += groupSummary.GroupsSkipped summary.Errors = append(summary.Errors, groupSummary.Errors...) if err != nil { - a.logger.Warn("intro marker group analysis failed", + a.logger.WarnContext(ctx, "intro marker group analysis failed", "season_id", group.SeasonID, "media_folder_id", group.MediaFolderID, "group_key", group.AnalysisGroupKey, @@ -261,7 +261,7 @@ func (a *Analyzer) AnalyzeEpisode(ctx context.Context, episodeID string) (RunSum summary.GroupsSkipped += groupSummary.GroupsSkipped summary.Errors = append(summary.Errors, groupSummary.Errors...) if err != nil { - a.logger.Warn("intro marker episode group analysis failed", + a.logger.WarnContext(ctx, "intro marker episode group analysis failed", "episode_id", episodeID, "season_id", group.SeasonID, "media_folder_id", group.MediaFolderID, @@ -335,7 +335,7 @@ func (a *Analyzer) processChapterCandidates(ctx context.Context, candidates []Ca }) if patchErr != nil { summary.Errors = append(summary.Errors, patchErr.Error()) - a.logger.Warn("intro marker chapter patch failed", "file_id", candidate.FileID, "error", patchErr) + a.logger.WarnContext(ctx, "intro marker chapter patch failed", "file_id", candidate.FileID, "error", patchErr) remaining = append(remaining, candidate) continue } @@ -393,7 +393,7 @@ func (a *Analyzer) processChapterCandidates(ctx context.Context, candidates []Ca if patchErr != nil { msg := fmt.Sprintf("file %d: %v", candidate.FileID, patchErr) summary.Errors = append(summary.Errors, msg) - a.logger.Warn("intro marker episode version copy failed", "file_id", candidate.FileID, "source_file_id", source.candidate.FileID, "error", patchErr) + a.logger.WarnContext(ctx, "intro marker episode version copy failed", "file_id", candidate.FileID, "source_file_id", source.candidate.FileID, "error", patchErr) unresolved = append(unresolved, candidate) continue } @@ -413,7 +413,7 @@ func (a *Analyzer) refineChapterSegment(ctx context.Context, candidate Candidate refined, ok, err := a.refiner.RefineChapterEnd(ctx, candidate, segment) if err != nil { summary.SilenceRefinementErrors++ - a.logger.Warn("intro marker silence refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) + a.logger.WarnContext(ctx, "intro marker silence refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) return segment } if ok { @@ -617,7 +617,7 @@ func (a *Analyzer) analyzeGroup(ctx context.Context, group candidateGroup, opts if patchErr != nil { msg := fmt.Sprintf("file %d: %v", fileID, patchErr) summary.Errors = append(summary.Errors, msg) - a.logger.Warn("intro marker chromaprint patch failed", "file_id", fileID, "path", candidate.FilePath, "error", patchErr) + a.logger.WarnContext(ctx, "intro marker chromaprint patch failed", "file_id", fileID, "path", candidate.FilePath, "error", patchErr) continue } if applied { @@ -643,7 +643,7 @@ func (a *Analyzer) refineChromaprintSegment(ctx context.Context, candidate Candi refined, ok, err := a.chromaprintRefiner.RefineChromaprintStart(ctx, candidate, segment) if err != nil { summary.DialogueRefinementErrors++ - a.logger.Warn("intro marker dialogue refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) + a.logger.WarnContext(ctx, "intro marker dialogue refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) return segment } if ok { @@ -722,7 +722,7 @@ func (a *Analyzer) ensureFingerprints(ctx context.Context, candidates []Candidat fp, ok, err := a.extractor.Extract(ctx, candidate) <-sem if err != nil { - a.logger.Warn("intro marker fingerprint extraction failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) + a.logger.WarnContext(ctx, "intro marker fingerprint extraction failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) return } if !ok { diff --git a/internal/jellycompat/access_filter.go b/internal/jellycompat/access_filter.go index b26dd390..bbf6e017 100644 --- a/internal/jellycompat/access_filter.go +++ b/internal/jellycompat/access_filter.go @@ -31,7 +31,7 @@ func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver { SkipPINVerification: true, }) if err != nil { - slog.Warn("jellycompat: access scope resolution failed; denying library access", + slog.WarnContext(ctx, "jellycompat: access scope resolution failed; denying library access", "component", "jellycompat", "user_id", userID, "profile_id", profileID, "error", err, diff --git a/internal/jellycompat/auth.go b/internal/jellycompat/auth.go index 7a250c27..334043c9 100644 --- a/internal/jellycompat/auth.go +++ b/internal/jellycompat/auth.go @@ -84,7 +84,7 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := ExtractToken(r) if !ok { - slog.Warn("jellycompat auth: no token in request", + slog.WarnContext(r.Context(), "jellycompat auth: no token in request", "component", "jellycompat", "path", r.URL.Path, "auth_header_present", r.Header.Get("Authorization") != "", "x_emby_auth_present", r.Header.Get("X-Emby-Authorization") != "", @@ -95,7 +95,7 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler { session, ok := a.sessions.Get(token) if !ok { - slog.Warn("jellycompat auth: session not found", + slog.WarnContext(r.Context(), "jellycompat auth: session not found", "component", "jellycompat", "path", r.URL.Path, "token_prefix", safeTokenPrefix(token), ) @@ -112,7 +112,7 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler { newPair, err := a.authService.Refresh(refreshCtx, session.StreamAppRefreshToken) cancel() if err != nil { - slog.Warn("jellycompat auth: token refresh failed, revoking session", + slog.WarnContext(r.Context(), "jellycompat auth: token refresh failed, revoking session", "component", "jellycompat", "path", r.URL.Path, "token_prefix", safeTokenPrefix(token), "error", err, @@ -128,7 +128,7 @@ func (a *Authenticator) RequireSession(next http.Handler) http.Handler { return nil }) if updateErr != nil { - slog.Warn("jellycompat auth: session update after refresh failed", + slog.WarnContext(r.Context(), "jellycompat auth: session update after refresh failed", "component", "jellycompat", "token_prefix", safeTokenPrefix(token), "error", updateErr, ) diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go index 885075d5..534d685d 100644 --- a/internal/jellycompat/auth_api_key.go +++ b/internal/jellycompat/auth_api_key.go @@ -228,7 +228,7 @@ func (a *AdminAPIKeyAuthenticator) resolveSession(ctx context.Context, token str profile, err := a.primaryProfile(ctx, user.ID) if err != nil { - slog.Warn("jellycompat api key session synthesis failed", "user_id", user.ID, "error", err) + slog.WarnContext(ctx, "jellycompat api key session synthesis failed", "component", "jellycompat", "user_id", user.ID, "error", err) return nil, adminAPIKeyAuthResult{ status: http.StatusUnauthorized, code: "Unauthorized", diff --git a/internal/jellycompat/cleanup.go b/internal/jellycompat/cleanup.go index d6202bd9..2a8ea54b 100644 --- a/internal/jellycompat/cleanup.go +++ b/internal/jellycompat/cleanup.go @@ -55,11 +55,11 @@ func StartSessionCleanupWithPlaybackStore(ctx context.Context, repo *SessionRepo case <-ticker.C: authDeleted, playbackDeleted, err := cleanupExpiredCompatState(ctx, repo, playbackExpirer, time.Now()) if err != nil { - slog.Warn("jellycompat session cleanup failed", "error", err) + slog.WarnContext(ctx, "jellycompat session cleanup failed", "component", "jellycompat", "error", err) continue } if authDeleted > 0 || playbackDeleted > 0 { - slog.Debug("jellycompat session cleanup", "auth_sessions", authDeleted, "playback_sessions", playbackDeleted) + slog.DebugContext(ctx, "jellycompat session cleanup", "component", "jellycompat", "auth_sessions", authDeleted, "playback_sessions", playbackDeleted) } } } diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 5eb8714c..2641dc16 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -741,7 +741,7 @@ func (s *directContentService) GetItemDetailsByIDs(ctx context.Context, session // below, which degrade one id at a time exactly like the // pre-batch path did. leafBatchFailed = true - slog.Warn("jellycompat: batch leaf progress lookup failed; falling back to per-item lookups", + slog.WarnContext(ctx, "jellycompat: batch leaf progress lookup failed; falling back to per-item lookups", "component", "jellycompat", "error", err, "leaf_count", len(leafIDs)) } } diff --git a/internal/jellycompat/handlers_autoscan.go b/internal/jellycompat/handlers_autoscan.go index 554b5ce4..25d28727 100644 --- a/internal/jellycompat/handlers_autoscan.go +++ b/internal/jellycompat/handlers_autoscan.go @@ -62,7 +62,7 @@ func (h *AutoscanHandler) HandleVirtualFolders(w http.ResponseWriter, r *http.Re } folders, err := h.folders.List(r.Context()) if err != nil { - slog.Error("jellycompat autoscan: listing libraries", "error", err) + slog.ErrorContext(r.Context(), "jellycompat autoscan: listing libraries", "component", "jellycompat", "error", err) writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to list libraries") return } @@ -128,7 +128,7 @@ func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Requ if err != nil { if parentTarget, handled, fallbackErr := resolveAutoscanParentTarget(r.Context(), resolver, path, err); handled { if fallbackErr != nil { - slog.Warn("jellycompat autoscan: media update parent path rejected", + slog.WarnContext(r.Context(), "jellycompat autoscan: media update parent path rejected", "component", "jellycompat", "path", path, "parent_path", filepath.Dir(filepath.Clean(path)), "update_type", update.UpdateType, @@ -138,7 +138,7 @@ func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Requ return } if parentTarget != nil { - slog.Debug("jellycompat autoscan: media update falling back to parent scan", + slog.DebugContext(r.Context(), "jellycompat autoscan: media update falling back to parent scan", "component", "jellycompat", "path", path, "parent_path", parentTarget.Path, "parent_mode", parentTarget.Mode, @@ -149,14 +149,14 @@ func (h *AutoscanHandler) HandleMediaUpdated(w http.ResponseWriter, r *http.Requ continue } if softAutoscanUpdateError(err) { - slog.Debug("jellycompat autoscan: media update ignored", + slog.DebugContext(r.Context(), "jellycompat autoscan: media update ignored", "component", "jellycompat", "path", path, "update_type", update.UpdateType, "error", err, ) continue } - slog.Warn("jellycompat autoscan: media update path rejected", + slog.WarnContext(r.Context(), "jellycompat autoscan: media update path rejected", "component", "jellycompat", "path", path, "update_type", update.UpdateType, "error", err, diff --git a/internal/jellycompat/handlers_collections.go b/internal/jellycompat/handlers_collections.go index 397d6a72..f8b0241a 100644 --- a/internal/jellycompat/handlers_collections.go +++ b/internal/jellycompat/handlers_collections.go @@ -108,7 +108,7 @@ func (h *ItemsHandler) collectionsViewVisible(ctx context.Context, libraries []u } visible, err := h.collections.AnyVisibleInLibraries(ctx, ids) if err != nil { - slog.DebugContext(ctx, "jellycompat collections view existence check failed", "error", err) + slog.DebugContext(ctx, "jellycompat collections view existence check failed", "component", "jellycompat", "error", err) return false } return visible @@ -515,14 +515,14 @@ func (h *ItemsHandler) smartCollectionContentIDs(ctx context.Context, session *S var def catalog.QueryDefinition if len(c.QueryDefinition) > 0 { if err := json.Unmarshal(c.QueryDefinition, &def); err != nil { - slog.DebugContext(ctx, "jellycompat smart collection query definition unmarshal failed", + slog.DebugContext(ctx, "jellycompat smart collection query definition unmarshal failed", "component", "jellycompat", "collection_id", c.ID, "error", err) return nil, nil } } def = def.Normalize() if err := def.ValidateWithOptions(false, false); err != nil { - slog.DebugContext(ctx, "jellycompat smart collection query definition invalid", + slog.DebugContext(ctx, "jellycompat smart collection query definition invalid", "component", "jellycompat", "collection_id", c.ID, "error", err) return nil, nil } diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index 4bbba358..a1f87f36 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -523,7 +523,7 @@ func (h *ItemsHandler) handlePersonItem(w http.ResponseWriter, r *http.Request, dto.BackdropImageTags = []string{} if counts, err := h.personRepo.CountItemsByType(r.Context(), personID); err != nil { - slog.Warn("failed to load filmography counts", "person_id", personID, "error", err) + slog.WarnContext(r.Context(), "failed to load filmography counts", "component", "jellycompat", "person_id", personID, "error", err) } else { dto.MovieCount = counts["movie"] dto.SeriesCount = counts["series"] @@ -757,14 +757,14 @@ func (h *ItemsHandler) HandleMediaSegments(w http.ResponseWriter, r *http.Reques } } if contentID == "" { - slog.Debug("jellycompat: media segments lookup with undecodable id", "raw_id", raw) + slog.DebugContext(r.Context(), "jellycompat: media segments lookup with undecodable id", "component", "jellycompat", "raw_id", raw) writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}}) return } detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil) if err != nil { - slog.Warn("jellycompat: media segments item lookup failed", + slog.WarnContext(r.Context(), "jellycompat: media segments item lookup failed", "component", "jellycompat", "content_id", contentID, "error", err) writeJSON(w, http.StatusOK, mediaSegmentsResultDTO{Items: []mediaSegmentDTO{}}) @@ -1067,7 +1067,7 @@ func (h *ItemsHandler) HandleLatest(w http.ResponseWriter, r *http.Request) { // path" signal (e.g. a client requesting a type other than the library's // own), not a failure — fall back quietly. if !errors.Is(err, errLatestNotEligible) { - slog.Warn("jellycompat: latest via sections failed, falling back to browse", "error", err) + slog.WarnContext(r.Context(), "jellycompat: latest via sections failed, falling back to browse", "component", "jellycompat", "error", err) } } result, err := h.content.BrowseItems(r.Context(), session, params) @@ -1450,7 +1450,7 @@ func (h *ItemsHandler) HandleGenreByName(w http.ResponseWriter, r *http.Request) func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) { defer func() { if rv := recover(); rv != nil { - slog.Error("jellycompat HandleSeasons panic", + slog.ErrorContext(r.Context(), "jellycompat HandleSeasons panic", "component", "jellycompat", "error", fmt.Sprint(rv), "path", r.URL.Path, "query", r.URL.RawQuery, @@ -2536,7 +2536,7 @@ func (h *ItemsHandler) handleResumeResponse(w http.ResponseWriter, r *http.Reque }) return } - slog.Warn("jellycompat: resume via sections failed, falling back to progress scan", "error", err) + slog.WarnContext(r.Context(), "jellycompat: resume via sections failed, falling back to progress scan", "component", "jellycompat", "error", err) } items, total, err := h.loadProgressPage(r.Context(), session, "in_progress", query, typeSet, nil) diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index de0118d9..85a017fe 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -483,7 +483,7 @@ func (h *PlaybackHandler) persistTranscodeRecipe( } } if recipe == nil { - slog.Warn("transcode recipe not persisted: upstream session unavailable", + slog.WarnContext(ctx, "transcode recipe not persisted: upstream session unavailable", "component", "jellycompat", "playback_session_id", upstreamSessionID) } @@ -503,7 +503,7 @@ func (h *PlaybackHandler) persistTranscodeRecipe( putCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) defer cancel() if err := h.RecipeNodeStore.Put(putCtx, upstreamSessionID, *recipe); err != nil { - slog.Warn("persist node transcode recipe failed", "error", err, + slog.WarnContext(ctx, "persist node transcode recipe failed", "component", "jellycompat", "error", err, "playback_session_id", upstreamSessionID, "node", recipe.TranscodeNodeURL) } } @@ -598,7 +598,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ // media default. Resolution falls back to honoring the request. downloaded = nil downloadedKnown = false - slog.Warn("jellycompat downloaded subtitle lookup failed", + slog.WarnContext(r.Context(), "jellycompat downloaded subtitle lookup failed", "component", "jellycompat", "file_id", source.Version.FileID, "error", listErr, ) diff --git a/internal/jellycompat/handlers_websocket.go b/internal/jellycompat/handlers_websocket.go index 9e05a904..55ab50b8 100644 --- a/internal/jellycompat/handlers_websocket.go +++ b/internal/jellycompat/handlers_websocket.go @@ -25,12 +25,12 @@ type wsMessage struct { func HandleSocket(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { - slog.Error("websocket upgrade failed", "error", err) + slog.ErrorContext(r.Context(), "websocket upgrade failed", "component", "jellycompat", "error", err) return } defer conn.Close() - slog.Info("websocket connected", + slog.InfoContext(r.Context(), "websocket connected", "component", "jellycompat", "remote_addr", r.RemoteAddr, "device_id", r.URL.Query().Get("deviceId"), ) @@ -45,7 +45,7 @@ func HandleSocket(w http.ResponseWriter, r *http.Request) { _, raw, err := conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - slog.Warn("websocket read error", "error", err) + slog.WarnContext(r.Context(), "websocket read error", "component", "jellycompat", "error", err) } return } @@ -59,7 +59,7 @@ func HandleSocket(w http.ResponseWriter, r *http.Request) { case "KeepAlive": conn.SetReadDeadline(time.Now().Add(60 * time.Second)) default: - slog.Debug("websocket message ignored", "type", msg.MessageType) + slog.DebugContext(r.Context(), "websocket message ignored", "component", "jellycompat", "type", msg.MessageType) } } } diff --git a/internal/jellycompat/logging.go b/internal/jellycompat/logging.go index 3ecd19ac..e11198e2 100644 --- a/internal/jellycompat/logging.go +++ b/internal/jellycompat/logging.go @@ -54,7 +54,7 @@ func requestLoggerMiddleware(next http.Handler) http.Handler { routePattern = routeCtx.RoutePattern() } - slog.Info("jellycompat request", + slog.InfoContext(r.Context(), "jellycompat request", "component", "jellycompat", "request_id", middleware.GetReqID(r.Context()), "method", r.Method, "path", r.URL.Path, diff --git a/internal/jellycompat/playback_sessions_postgres.go b/internal/jellycompat/playback_sessions_postgres.go index 75c2b598..926cf004 100644 --- a/internal/jellycompat/playback_sessions_postgres.go +++ b/internal/jellycompat/playback_sessions_postgres.go @@ -272,7 +272,7 @@ func (d *DurableCompatPlaybackStore) upsert(ctx context.Context, session Playbac } data, err := json.Marshal(session) if err != nil { - slog.Warn("marshal compat playback session failed", "error", err, "play_session_id", session.ID) + slog.WarnContext(ctx, "marshal compat playback session failed", "component", "jellycompat", "error", err, "play_session_id", session.ID) return } expiresAt := session.ExpiresAt @@ -280,7 +280,7 @@ func (d *DurableCompatPlaybackStore) upsert(ctx context.Context, session Playbac expiresAt = d.now().Add(d.ttl) } if _, err := d.pool.Exec(ctx, upsertSessionQuery, session.ID, session.CompatToken, session.UserID, data, expiresAt); err != nil { - slog.Warn("persist compat playback session failed", "error", err, "play_session_id", session.ID) + slog.WarnContext(ctx, "persist compat playback session failed", "component", "jellycompat", "error", err, "play_session_id", session.ID) } } diff --git a/internal/jellycompat/profile_refresh.go b/internal/jellycompat/profile_refresh.go index f27c73b6..d7a970ee 100644 --- a/internal/jellycompat/profile_refresh.go +++ b/internal/jellycompat/profile_refresh.go @@ -16,7 +16,7 @@ type profileRefreshRequester interface { func triggerProfileRefresh(ctx context.Context, staler profileStaler, requester profileRefreshRequester, userID int, profileID string) { if staler != nil { if err := staler.MarkProfileStale(ctx, userID, profileID); err != nil { - slog.Warn("failed to mark profile stale", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "failed to mark profile stale", "component", "jellycompat", "user_id", userID, "profile_id", profileID, "error", err) } } if requester != nil { diff --git a/internal/jellycompat/query.go b/internal/jellycompat/query.go index 5a254e1a..0ba62565 100644 --- a/internal/jellycompat/query.go +++ b/internal/jellycompat/query.go @@ -186,7 +186,7 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery { // for Chapters/MediaSources/People to trigger detail). if !result.needsDetailFields { if missing := unsatisfiedListFields(result.requestedFields); len(missing) > 0 { - slog.DebugContext(r.Context(), "jellycompat unsatisfied fields", + slog.DebugContext(r.Context(), "jellycompat unsatisfied fields", "component", "jellycompat", "path", r.URL.Path, "fields", missing, "hint", "list-path response will omit these; add a detail-required field (e.g. MediaSources) to switch paths") diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 5cd14319..78cbb544 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -409,7 +409,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques if !decision.Progress.LastProducedAt.IsZero() { lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds() } - slog.Info("transcode segment missing", + slog.InfoContext(r.Context(), "transcode segment missing", "component", "jellycompat", "segment", segmentFile, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -423,7 +423,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques "playback_session_id", playSession.UpstreamSessionID, ) if decision.Wait { - slog.Info("transcode segment wait", + slog.InfoContext(r.Context(), "transcode segment wait", "component", "jellycompat", "segment", segmentFile, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -438,7 +438,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques ) segmentPath, err = transcodeSession.WaitForSegment(segmentFile, decision.WaitTimeout) if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { - slog.Info("transcode segment wait timeout", + slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "jellycompat", "segment", segmentFile, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -457,7 +457,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques if err != nil && errors.Is(err, playback.ErrSegmentNotFound) && decision.RestartOnTimeout { seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum) if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) { - slog.Error("resolve transcode seek target", + slog.ErrorContext(r.Context(), "resolve transcode seek target", "component", "jellycompat", "error", seekErr, "segment", segmentFile, "play_session", playSessionID, @@ -477,7 +477,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques } if ok { - slog.Info("transcode seek restart", + slog.InfoContext(r.Context(), "transcode seek restart", "component", "jellycompat", "segment", segmentFile, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -805,7 +805,7 @@ func (h *PlaybackHandler) teardownPlaySession(ctx context.Context, playSession * delCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 2*time.Second) defer cancel() if err := h.RecipeNodeStore.Delete(delCtx, playSession.UpstreamSessionID); err != nil { - slog.Warn("delete node transcode recipe failed", "error", err, + slog.WarnContext(ctx, "delete node transcode recipe failed", "component", "jellycompat", "error", err, "playback_session_id", playSession.UpstreamSessionID) } } @@ -830,7 +830,7 @@ func (h *PlaybackHandler) syncSessionsNow(ctx context.Context, reason string) { ctx, cancel := context.WithTimeout(ctx, compatSessionSyncTimeout) defer cancel() if err := h.SessionSyncer.SyncNow(ctx); err != nil { - slog.Error("jellycompat: failed to sync sessions", "reason", reason, "error", err) + slog.ErrorContext(ctx, "jellycompat: failed to sync sessions", "component", "jellycompat", "reason", reason, "error", err) } } @@ -904,7 +904,7 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re audioTrackIndex = resolvedAudioTrackIndex } if syncErr := h.syncUpstreamAudioSelection(playSession, *updatedSource); syncErr != nil { - slog.Warn("jellycompat audio selection sync failed", + slog.WarnContext(r.Context(), "jellycompat audio selection sync failed", "component", "jellycompat", "play_session_id", playSession.ID, "upstream_session_id", playSession.UpstreamSessionID, "error", syncErr, @@ -912,14 +912,14 @@ func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Re } restarted, restartErr := h.restartCompatTranscodeForAudioSelection(r.Context(), playSession, *updatedSource, positionSeconds) if restartErr != nil { - slog.Warn("jellycompat audio selection restart failed", + slog.WarnContext(r.Context(), "jellycompat audio selection restart failed", "component", "jellycompat", "play_session_id", playSession.ID, "upstream_session_id", playSession.UpstreamSessionID, "error", restartErr, ) } audioRestarted = restarted - slog.Info("jellycompat audio selection updated", + slog.InfoContext(r.Context(), "jellycompat audio selection updated", "component", "jellycompat", "play_session_id", playSession.ID, "media_source_id", updatedSource.ID, "audio_stream_index", selectedAudioStreamIndex, @@ -1011,7 +1011,7 @@ func (h *PlaybackHandler) reviveUpstreamForReport(ctx context.Context, session * } revived, err := h.ensureUpstreamPlayback(ctx, session, playSession.ID, *source, playSession.UpstreamPlayMethod) if err != nil { - slog.Warn("jellycompat upstream session revive failed", + slog.WarnContext(ctx, "jellycompat upstream session revive failed", "component", "jellycompat", "play_session_id", playSession.ID, "upstream_session_id", playSession.UpstreamSessionID, "error", err, @@ -1093,7 +1093,7 @@ func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSess if h.RecipeNodeStore != nil { delCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 2*time.Second) if err := h.RecipeNodeStore.Delete(delCtx, oldUpstreamSessionID); err != nil { - slog.Warn("delete node transcode recipe failed", "error", err, + slog.WarnContext(ctx, "delete node transcode recipe failed", "component", "jellycompat", "error", err, "playback_session_id", oldUpstreamSessionID) } cancel() @@ -1380,7 +1380,7 @@ func (h *PlaybackHandler) restartCompatTranscodeForAudioSelection( // not the live stream. opts := transcodeSession.Opts() if err := h.persistTranscodeRecipe(context.WithoutCancel(ctx), playSession.ID, playSession.UpstreamSessionID, opts); err != nil { - slog.Warn("persist audio-restarted transcode recipe", "error", err, + slog.WarnContext(ctx, "persist audio-restarted transcode recipe", "component", "jellycompat", "error", err, "playback_session_id", playSession.ID) } return true, nil diff --git a/internal/jellycompat/userdata_direct.go b/internal/jellycompat/userdata_direct.go index df1d2fe6..68b760ee 100644 --- a/internal/jellycompat/userdata_direct.go +++ b/internal/jellycompat/userdata_direct.go @@ -214,7 +214,7 @@ func (s *directUserDataService) FilterResumeProgress(ctx context.Context, sessio // Dismissal lookup failures degrade to showing the entries, matching the // first-party fetcher. if dismissals, err := store.ListHomeDismissals(ctx, session.ProfileID, userstore.HomeSurfaceContinueWatching); err != nil { - slog.Error("listing continue watching dismissals", "profile_id", session.ProfileID, "error", err) + slog.ErrorContext(ctx, "listing continue watching dismissals", "component", "jellycompat", "profile_id", session.ProfileID, "error", err) } else { progress = catalog.NewHomeDismissalIndex(dismissals).FilterProgress(progress) } diff --git a/internal/libraryingest/executor.go b/internal/libraryingest/executor.go index 3afbadec..eb626fb4 100644 --- a/internal/libraryingest/executor.go +++ b/internal/libraryingest/executor.go @@ -181,7 +181,7 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode drainerErrCh := make(chan error, 1) var drainerWG sync.WaitGroup if len(matchScopes) > 0 { - slog.Info("library ingest: concurrent scoped matching started", + slog.InfoContext(ctx, "library ingest: concurrent scoped matching started", "component", "libraryingest", "folder_id", folder.ID, "mode", mode, "scope_count", len(matchScopes), @@ -249,7 +249,7 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode stopDrainers() drainerWG.Wait() if len(matchScopes) > 0 { - slog.Info("library ingest: concurrent scoped matching stopped", + slog.InfoContext(ctx, "library ingest: concurrent scoped matching stopped", "component", "libraryingest", "folder_id", folder.ID, "mode", mode, "scope_count", len(matchScopes), @@ -269,7 +269,7 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode if settleWindow <= 0 { settleWindow = scopedTVDrainSettleWindow } - slog.Info("library ingest: waiting for tv queue settle window", + slog.InfoContext(ctx, "library ingest: waiting for tv queue settle window", "component", "libraryingest", "folder_id", folder.ID, "mode", mode, "wait", settleWindow, @@ -288,7 +288,7 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode stopDrainers() drainerWG.Wait() if len(matchScopes) > 0 { - slog.Info("library ingest: concurrent scoped matching stopped", + slog.InfoContext(ctx, "library ingest: concurrent scoped matching stopped", "component", "libraryingest", "folder_id", folder.ID, "mode", mode, "scope_count", len(matchScopes), @@ -415,7 +415,7 @@ func (e *Executor) ingest(ctx context.Context, folder *models.MediaFolder, mode Errors: scanResultCount(scanResult, func(value *scanner.ScanResult) int { return value.Errors }), }) - slog.Info("library ingest: completed", + slog.InfoContext(ctx, "library ingest: completed", "component", "libraryingest", "folder_id", folder.ID, "mode", mode, "scope", claim.path, diff --git a/internal/logredact/redact.go b/internal/logredact/redact.go new file mode 100644 index 00000000..1bbf7229 --- /dev/null +++ b/internal/logredact/redact.go @@ -0,0 +1,160 @@ +// Package logredact provides secret redaction for slog output sinks (console, +// file, OTLP). The opslog database path does its own redaction when flattening +// records into rows; this package covers the remaining sinks so secrets do not +// leak to stderr, a mounted log file, or an OTLP collector. +// +// Redaction is key-based: an attribute whose key looks secret-bearing (token, +// password, api_key, ...) has its value replaced with the placeholder. Values +// are not scanned, matching the opslog DB-path behavior — a secret embedded in +// a free-text message or a non-secret-keyed value is not caught. +package logredact + +import ( + "context" + "log/slog" + "strings" +) + +// Placeholder replaces the value of a redacted attribute. +const Placeholder = "[REDACTED]" + +// secretMarkers are substrings that, when present in a lower-cased attribute +// key, mark the value as secret-bearing. Kept in sync with the opslog DB path +// by being the single shared source (opslog.shouldRedact delegates here). +var secretMarkers = []string{ + "password", "secret", "token", "api_key", "apikey", "authorization", "cookie", +} + +// SecretKey reports whether an attribute key names a secret-bearing value. +func SecretKey(key string) bool { + key = strings.ToLower(key) + for _, marker := range secretMarkers { + if strings.Contains(key, marker) { + return true + } + } + return false +} + +// Handler wraps an slog.Handler, redacting secret-bearing attributes (including +// those bound via WithAttrs and nested groups) before they reach the inner +// handler. It preserves level gating and best-effort semantics of whatever it +// wraps by delegating Enabled/Handle to the inner handler. +type Handler struct { + inner slog.Handler + // redactAll is set once the logger enters a group whose name is + // secret-bearing (e.g. WithGroup("authorization")). Inside such a subtree + // every leaf is masked regardless of its own key, matching how a + // slog.Group("authorization", ...) value is masked as a whole. It stays set + // for all descendant groups. + redactAll bool +} + +// New returns a Handler wrapping inner. +func New(inner slog.Handler) *Handler { + return &Handler{inner: inner} +} + +// Enabled delegates to the inner handler. +func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +// Handle redacts the record's attributes and forwards to the inner handler. +// When the record carries no secret-bearing keys, the original record is passed +// through unchanged to avoid rebuilding it on the hot path. +func (h *Handler) Handle(ctx context.Context, r slog.Record) error { + if !h.redactAll && !recordNeedsRedaction(r) { + return h.inner.Handle(ctx, r) + } + nr := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + r.Attrs(func(a slog.Attr) bool { + nr.AddAttrs(redactAttr(a, h.redactAll)) + return true + }) + return h.inner.Handle(ctx, nr) +} + +// WithAttrs redacts the bound attributes so secrets attached via a logger's +// With(...) are also masked. +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + redacted := make([]slog.Attr, len(attrs)) + for i, a := range attrs { + redacted[i] = redactAttr(a, h.redactAll) + } + return &Handler{inner: h.inner.WithAttrs(redacted), redactAll: h.redactAll} +} + +// WithGroup delegates to the inner handler; grouped attributes are still +// redacted by leaf key at Handle/WithAttrs time. A group whose name is itself +// secret-bearing masks every leaf within the subtree, so nothing logged under +// it (e.g. WithGroup("authorization").Info(..., "value", token)) leaks. +func (h *Handler) WithGroup(name string) slog.Handler { + return &Handler{ + inner: h.inner.WithGroup(name), + redactAll: h.redactAll || SecretKey(name), + } +} + +// recordNeedsRedaction reports whether any record-level attribute (or nested +// group attribute) has a secret-bearing key. +func recordNeedsRedaction(r slog.Record) bool { + found := false + r.Attrs(func(a slog.Attr) bool { + if attrHasSecret(a) { + found = true + return false + } + return true + }) + return found +} + +func attrHasSecret(a slog.Attr) bool { + // Resolve LogValuer values so a secret hidden behind one (e.g. a type whose + // LogValue() returns a token) is inspected, not passed through opaque. + a.Value = a.Value.Resolve() + // A secret-bearing key masks the whole attribute — including a group whose + // own key is secret (its members are never reached below). + if SecretKey(a.Key) { + return true + } + if a.Value.Kind() == slog.KindGroup { + for _, ga := range a.Value.Group() { + if attrHasSecret(ga) { + return true + } + } + } + return false +} + +// redactAttr masks a's value when its key is secret-bearing. When force is set +// (the logger is inside a secret-named group), every leaf is masked regardless +// of its own key, while group structure is preserved so the subtree stays +// well-formed. +func redactAttr(a slog.Attr, force bool) slog.Attr { + // On the Handle slow path this re-invokes any LogValuer that attrHasSecret + // already resolved (on its own copy). slog requires LogValue to be cheap and + // side-effect free, so the double call is acceptable and is what keeps the + // no-secrets fast path allocation-free. + a.Value = a.Value.Resolve() + if a.Value.Kind() == slog.KindGroup { + // A group whose own key is secret collapses to a single placeholder, + // matching the leaf case; otherwise recurse, propagating force. + if SecretKey(a.Key) { + return slog.String(a.Key, Placeholder) + } + group := a.Value.Group() + out := make([]slog.Attr, len(group)) + for i, ga := range group { + out[i] = redactAttr(ga, force) + } + return slog.Attr{Key: a.Key, Value: slog.GroupValue(out...)} + } + // Leaf: mask when forced or when the key itself is secret-bearing. + if force || SecretKey(a.Key) { + return slog.String(a.Key, Placeholder) + } + return a +} diff --git a/internal/logredact/redact_test.go b/internal/logredact/redact_test.go new file mode 100644 index 00000000..c7b37eb5 --- /dev/null +++ b/internal/logredact/redact_test.go @@ -0,0 +1,166 @@ +package logredact + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "strings" + "testing" + "testing/slogtest" +) + +func TestSlogtestConformance(t *testing.T) { + var buf bytes.Buffer + h := New(slog.NewJSONHandler(&buf, nil)) + results := func() []map[string]any { + var ms []map[string]any + for _, line := range bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte{'\n'}) { + if len(line) == 0 { + continue + } + var m map[string]any + if err := json.Unmarshal(line, &m); err != nil { + t.Fatalf("unmarshal %q: %v", line, err) + } + ms = append(ms, m) + } + return ms + } + if err := slogtest.TestHandler(h, results); err != nil { + t.Fatalf("slogtest: %v", err) + } +} + +func TestSecretKey(t *testing.T) { + for _, k := range []string{"password", "api_key", "apiKey", "X-Authorization", "session_token", "Cookie", "client_secret"} { + if !SecretKey(k) { + t.Errorf("SecretKey(%q) = false, want true", k) + } + } + for _, k := range []string{"user_id", "request_id", "component", "folder", "count"} { + if SecretKey(k) { + t.Errorf("SecretKey(%q) = true, want false", k) + } + } +} + +func logAndCapture(t *testing.T, fn func(l *slog.Logger)) map[string]any { + t.Helper() + var buf bytes.Buffer + base := slog.NewJSONHandler(&buf, nil) + l := slog.New(New(base)) + fn(l) + var out map[string]any + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v (raw: %s)", err, buf.String()) + } + return out +} + +func TestHandlerRedactsRecordAttr(t *testing.T) { + out := logAndCapture(t, func(l *slog.Logger) { + l.InfoContext(context.Background(), "auth", "user_id", 7, "api_token", "s3cr3t") + }) + if out["api_token"] != Placeholder { + t.Errorf("api_token = %v, want %s", out["api_token"], Placeholder) + } + if out["user_id"].(float64) != 7 { + t.Errorf("user_id = %v, want 7 (non-secret must pass through)", out["user_id"]) + } + if strings.Contains(out["api_token"].(string), "s3cr3t") { + t.Error("secret value leaked") + } +} + +func TestHandlerRedactsWithAttrs(t *testing.T) { + out := logAndCapture(t, func(l *slog.Logger) { + l.With("password", "hunter2", "component", "auth").InfoContext(context.Background(), "bound") + }) + if out["password"] != Placeholder { + t.Errorf("bound password = %v, want %s", out["password"], Placeholder) + } + if out["component"] != "auth" { + t.Errorf("component = %v, want auth", out["component"]) + } +} + +func TestHandlerRedactsGroup(t *testing.T) { + out := logAndCapture(t, func(l *slog.Logger) { + l.InfoContext(context.Background(), "grp", slog.Group("creds", "authorization", "Bearer x", "kind", "oauth")) + }) + creds, ok := out["creds"].(map[string]any) + if !ok { + t.Fatalf("creds group missing: %v", out) + } + if creds["authorization"] != Placeholder { + t.Errorf("grouped authorization = %v, want %s", creds["authorization"], Placeholder) + } + if creds["kind"] != "oauth" { + t.Errorf("grouped kind = %v, want oauth", creds["kind"]) + } +} + +func TestHandlerRedactsSecretGroupKey(t *testing.T) { + // A group whose OWN key is a secret marker must have its entire subtree + // masked, not just secret leaf keys within it. + out := logAndCapture(t, func(l *slog.Logger) { + l.InfoContext(context.Background(), "x", + slog.Group("authorization", "scheme", "Bearer", "value", "abc123SECRET")) + }) + if out["authorization"] != Placeholder { + t.Errorf("secret group key = %v, want %s (whole subtree masked)", out["authorization"], Placeholder) + } + if strings.Contains(mustJSON(t, out), "abc123SECRET") { + t.Error("secret value leaked from secret-keyed group") + } +} + +type secretValuer struct{ tok string } + +func (s secretValuer) LogValue() slog.Value { + return slog.GroupValue(slog.String("token", s.tok)) +} + +func TestHandlerResolvesLogValuer(t *testing.T) { + out := logAndCapture(t, func(l *slog.Logger) { + l.InfoContext(context.Background(), "x", "creds", secretValuer{tok: "abc123SECRET"}) + }) + if strings.Contains(mustJSON(t, out), "abc123SECRET") { + t.Errorf("secret behind LogValuer leaked: %v", out) + } +} + +func TestHandlerRedactsUnderWithGroup(t *testing.T) { + out := logAndCapture(t, func(l *slog.Logger) { + l.WithGroup("req").InfoContext(context.Background(), "x", "api_key", "abc123SECRET", "path", "/v1") + }) + req, ok := out["req"].(map[string]any) + if !ok { + t.Fatalf("req group missing: %v", out) + } + if req["api_key"] != Placeholder { + t.Errorf("api_key under WithGroup = %v, want %s", req["api_key"], Placeholder) + } + if req["path"] != "/v1" { + t.Errorf("path under WithGroup = %v, want /v1", req["path"]) + } +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +func TestHandlerPassThroughWhenClean(t *testing.T) { + out := logAndCapture(t, func(l *slog.Logger) { + l.InfoContext(context.Background(), "clean", "user_id", 1, "component", "api") + }) + if out["user_id"].(float64) != 1 || out["component"] != "api" { + t.Errorf("clean record altered: %v", out) + } +} diff --git a/internal/manga/enrichment.go b/internal/manga/enrichment.go index 273931d6..0b88fc9f 100644 --- a/internal/manga/enrichment.go +++ b/internal/manga/enrichment.go @@ -162,14 +162,14 @@ func (e *Enricher) Run(ctx context.Context) (int, error) { return 0, nil } - slog.Info("manga enrichment: sweep started", + slog.InfoContext(ctx, "manga enrichment: sweep started", "component", "manga", "count", len(items), "workers", e.workers, ) stats := e.runBatch(ctx, items, e.enrichItem, e.recordEnrichFailure) - slog.Info("manga enrichment: sweep complete", + slog.InfoContext(ctx, "manga enrichment: sweep complete", "component", "manga", "attempted", len(items), "enriched", stats.enriched, "no_match", stats.noMatch, @@ -215,7 +215,7 @@ func (e *Enricher) runBatch( } if err := enrichFn(ctx, item); err != nil { if errors.Is(err, errEnrichmentSkipped) { - slog.Debug("manga enrichment: item skipped", + slog.DebugContext(ctx, "manga enrichment: item skipped", "component", "manga", "content_id", item.ContentID, "title", item.Title, "reason", err, @@ -226,7 +226,7 @@ func (e *Enricher) runBatch( atomic.AddInt64(&stats.noMatch, 1) continue } - slog.Warn("manga enrichment: item failed", + slog.WarnContext(ctx, "manga enrichment: item failed", "component", "manga", "content_id", item.ContentID, "title", item.Title, "error", err, @@ -397,7 +397,7 @@ func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemR return fmt.Errorf("no metadata obtained, %d provider error(s): %w", len(providerErrs), errors.Join(providerErrs...)) } - slog.Info("manga enrichment: no metadata found", + slog.InfoContext(ctx, "manga enrichment: no metadata found", "component", "manga", "content_id", item.ContentID, "title", item.Title, ) @@ -412,7 +412,7 @@ func (e *Enricher) enrichWithProviders(ctx context.Context, item enrichmentItemR } e.enqueueRemoteArtwork(ctx, item.ContentID, accumulator) - slog.Info("manga enrichment: enriched", + slog.InfoContext(ctx, "manga enrichment: enriched", "component", "manga", "content_id", item.ContentID, "title", item.Title, "poster", accumulator.PosterPath != "", @@ -452,7 +452,7 @@ func (e *Enricher) enrichSecondaryOnly(ctx context.Context, item enrichmentItemR return fmt.Errorf("no secondary metadata obtained, %d provider error(s): %w", len(providerErrs), errors.Join(providerErrs...)) } - slog.Info("manga enrichment: no secondary metadata available", + slog.InfoContext(ctx, "manga enrichment: no secondary metadata available", "component", "manga", "content_id", item.ContentID, "title", item.Title, ) @@ -469,7 +469,7 @@ func (e *Enricher) enrichSecondaryOnly(ctx context.Context, item enrichmentItemR e.enqueueRemoteImage(ctx, item.ContentID, result.BackdropPath, metadata.ImageBackdrop) } - slog.Info("manga enrichment: secondary metadata added", + slog.InfoContext(ctx, "manga enrichment: secondary metadata added", "component", "manga", "content_id", item.ContentID, "title", item.Title, "backdrop", upd.BackdropPath != nil, @@ -502,7 +502,7 @@ func collectMangaMetadata(ctx context.Context, item enrichmentItemRow, providers } results, searchErr := sp.Search(ctx, searchQuery) if searchErr != nil { - slog.Warn("manga enrichment: search error", + slog.WarnContext(ctx, "manga enrichment: search error", "component", "manga", "provider", p.Slug(), "content_id", item.ContentID, "error", searchErr, @@ -520,7 +520,7 @@ func collectMangaMetadata(ctx context.Context, item enrichmentItemRow, providers } } } - slog.Debug("manga enrichment: search result", + slog.DebugContext(ctx, "manga enrichment: search result", "component", "manga", "provider", p.Slug(), "content_id", item.ContentID, "matched_ids", accumulatedIDs, @@ -538,7 +538,7 @@ func collectMangaMetadata(ctx context.Context, item enrichmentItemRow, providers } result, getErr := mp.GetMetadata(ctx, buildMangaMetadataRequest(accumulator.ProviderIDs, item.Language)) if getErr != nil { - slog.Warn("manga enrichment: GetMetadata error", + slog.WarnContext(ctx, "manga enrichment: GetMetadata error", "component", "manga", "provider", p.Slug(), "content_id", item.ContentID, "error", getErr, @@ -556,7 +556,7 @@ func collectMangaMetadata(ctx context.Context, item enrichmentItemRow, providers // would fail the no-match check below and be discarded + stamped. accumulator.HasMetadata = true - slog.Debug("manga enrichment: metadata received", + slog.DebugContext(ctx, "manga enrichment: metadata received", "component", "manga", "provider", p.Slug(), "content_id", item.ContentID, "has_poster", result.PosterPath != "", @@ -610,7 +610,7 @@ func (e *Enricher) cacheRemoteImage(ctx context.Context, contentID, url string, ImageType: imageType, }) if err != nil { - slog.Warn("manga enrichment: image cache failed, keeping provider URL", + slog.WarnContext(ctx, "manga enrichment: image cache failed, keeping provider URL", "component", "manga", "content_id", contentID, "url", url, "error", err, @@ -618,7 +618,7 @@ func (e *Enricher) cacheRemoteImage(ctx context.Context, contentID, url string, return url, "" } if cached == nil { - slog.Warn("manga enrichment: image cache returned no result, keeping provider URL", + slog.WarnContext(ctx, "manga enrichment: image cache returned no result, keeping provider URL", "component", "manga", "content_id", contentID, "url", url, ) @@ -718,7 +718,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma providerIDs = filterMangaProviderIDs(providerIDs) if e.providerIDs != nil && len(providerIDs) > 0 { if err := e.providerIDs.ReplaceByContentID(ctx, contentID, providerIDs); err != nil { - slog.Warn("manga enrichment: failed to persist provider IDs", + slog.WarnContext(ctx, "manga enrichment: failed to persist provider IDs", "component", "manga", "content_id", contentID, "error", err, ) @@ -732,7 +732,7 @@ func (e *Enricher) persist(ctx context.Context, contentID string, providerIDs ma authors := filterMangaPeople(result.People) if len(authors) > 0 && e.personRepo != nil && e.itemRepo != nil { if err := e.persistPeople(ctx, contentID, authors); err != nil { - slog.Warn("manga enrichment: failed to persist people", + slog.WarnContext(ctx, "manga enrichment: failed to persist people", "component", "manga", "content_id", contentID, "error", err, ) @@ -768,7 +768,7 @@ func (e *Enricher) enqueueRemoteImage(ctx context.Context, contentID, sourcePath ImageType: metadata.ImageTypeToString(imageType), }}) if err != nil { - slog.Warn("manga enrichment: failed to enqueue image cache job", + slog.WarnContext(ctx, "manga enrichment: failed to enqueue image cache job", "component", "manga", "content_id", contentID, "image_type", metadata.ImageTypeToString(imageType), "error", err, @@ -828,7 +828,7 @@ func (e *Enricher) recordEnrichFailure(ctx context.Context, item enrichmentItemR failures = manga_enrichment_state.failures + 1, updated_at = NOW() `, item.ContentID); err != nil { - slog.Warn("manga enrichment: failed to record enrichment failure", + slog.WarnContext(ctx, "manga enrichment: failed to record enrichment failure", "component", "manga", "content_id", item.ContentID, "error", err, ) diff --git a/internal/markers/contribute.go b/internal/markers/contribute.go index 46bc14b5..87a9c4d1 100644 --- a/internal/markers/contribute.go +++ b/internal/markers/contribute.go @@ -239,7 +239,7 @@ func (s *ContributionService) contributeSegment( row.Status = OutcomeStatusError row.Error = &msg if recErr := s.store.Record(ctx, row); recErr != nil { - s.logger.Warn("record contribution error failed", "file_id", file.ID, "provider", providerID, "segment", seg.name, "error", recErr) + s.logger.WarnContext(ctx, "record contribution error failed", "file_id", file.ID, "provider", providerID, "segment", seg.name, "error", recErr) } if after, ok := RetryAfter(err); ok { return ContributionOutcome{Provider: providerID, Segment: seg.kind, Status: OutcomeStatusRateLimited, Reason: msg, RetryAfter: after}, true @@ -256,7 +256,7 @@ func (s *ContributionService) contributeSegment( row.SubmissionID = &id } if err := s.store.Record(ctx, row); err != nil { - s.logger.Warn("record contribution failed", "file_id", file.ID, "provider", providerID, "segment", seg.name, "error", err) + s.logger.WarnContext(ctx, "record contribution failed", "file_id", file.ID, "provider", providerID, "segment", seg.name, "error", err) } return ContributionOutcome{Provider: providerID, Segment: seg.kind, Status: row.Status, SubmissionID: result.ID}, true } diff --git a/internal/metadata/chain.go b/internal/metadata/chain.go index 79e4cf2d..8022d774 100644 --- a/internal/metadata/chain.go +++ b/internal/metadata/chain.go @@ -376,7 +376,7 @@ func resolveEnabledProvidersByPriority( for _, c := range caps { metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID, c.CapabilityID) if !providerSupportsLevel(metadataJSON, contentLevel) { - slog.Debug("skipping metadata provider: does not declare support for content level", + slog.DebugContext(ctx, "skipping metadata provider: does not declare support for content level", "component", "metadata", "installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID, "content_level", contentLevel) @@ -600,14 +600,14 @@ func buildProviders( for _, c := range caps { enabled, err := installationEnabled(ctx, checker, pool, c.PluginInstallationID) if err != nil || !enabled { - slog.Debug("skipping metadata provider: plugin installation disabled", + slog.DebugContext(ctx, "skipping metadata provider: plugin installation disabled", "component", "metadata", "installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID) continue } provider, err := NewPluginProviderFromCapability(c.PluginInstallationID, c.CapabilityID, c.DisplayName, resolver) if err != nil { - slog.Warn("skipping metadata provider during chain resolution", + slog.WarnContext(ctx, "skipping metadata provider during chain resolution", "component", "metadata", "installation_id", c.PluginInstallationID, "capability_id", c.CapabilityID, "error", err, diff --git a/internal/metadata/image_cache_processor.go b/internal/metadata/image_cache_processor.go index 0f475cae..d5db69fe 100644 --- a/internal/metadata/image_cache_processor.go +++ b/internal/metadata/image_cache_processor.go @@ -213,7 +213,7 @@ loop: if len(unstarted) > 0 { requeueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) if err := p.jobs.RequeueClaimed(requeueCtx, unstarted, workerID); err != nil { - p.logger.Warn("metadata image cache: failed to requeue unstarted jobs", "count", len(unstarted), "error", err) + p.logger.WarnContext(ctx, "metadata image cache: failed to requeue unstarted jobs", "count", len(unstarted), "error", err) } cancel() } @@ -315,7 +315,7 @@ func (p *ImageCacheProcessor) cleanupSucceeded(ctx context.Context, stats *Image defer cancel() deleted, err := p.jobs.DeleteSucceededBefore(cleanupCtx, time.Now().Add(-30*24*time.Hour), 1000) if err != nil { - p.logger.Warn("metadata image cache: failed to delete old succeeded jobs", "error", err) + p.logger.WarnContext(ctx, "metadata image cache: failed to delete old succeeded jobs", "error", err) } else { stats.DeletedSucceeded = deleted } @@ -329,7 +329,7 @@ func (p *ImageCacheProcessor) markFailed(parent context.Context, job *models.Met writeCtx, cancel := terminalJobContext(parent) defer cancel() if err := p.jobs.MarkFailed(writeCtx, job.ID, job.AttemptCount, job.LockedBy, errText); err != nil { - p.logger.Warn("metadata image cache: failed to mark job failed", "job_id", job.ID, "error", err) + p.logger.WarnContext(parent, "metadata image cache: failed to mark job failed", "job_id", job.ID, "error", err) } } @@ -337,7 +337,7 @@ func (p *ImageCacheProcessor) markSucceeded(parent context.Context, job *models. writeCtx, cancel := terminalJobContext(parent) defer cancel() if err := p.jobs.MarkSucceeded(writeCtx, job.ID, job.LockedBy); err != nil { - p.logger.Warn("metadata image cache: failed to mark job succeeded", "job_id", job.ID, "error", err) + p.logger.WarnContext(parent, "metadata image cache: failed to mark job succeeded", "job_id", job.ID, "error", err) } } diff --git a/internal/metadata/image_resolver.go b/internal/metadata/image_resolver.go index a2f9c885..67ec82d8 100644 --- a/internal/metadata/image_resolver.go +++ b/internal/metadata/image_resolver.go @@ -233,13 +233,13 @@ func (r *PluginImageResolver) ResolveImageURLsWithExpiry(ctx context.Context, pa } sources := sourcesSnapshot[pluginID] if len(sources) == 0 { - slog.Warn("no image resolver registered for scheme", "scheme", pluginID) + slog.WarnContext(ctx, "no image resolver registered for scheme", "component", "metadata", "scheme", pluginID) return map[string]catalog.ResolvedImageURL{}, nil } return r.resolvePluginBatchWithFallback(ctx, pluginID, sources, entries, variant), nil }) if err != nil { - slog.Error("image batch resolution failed", "plugin_id", pluginID, "error", err) + slog.ErrorContext(ctx, "image batch resolution failed", "component", "metadata", "plugin_id", pluginID, "error", err) continue } @@ -276,14 +276,14 @@ func (r *PluginImageResolver) resolvePluginBatchWithFallback( resolvedBatch, err := r.resolvePluginBatch(ctx, source.source, remaining, variant) if err != nil { if status.Code(err) == codes.Unimplemented { - slog.Debug("plugin image resolver source does not implement image resolution", + slog.DebugContext(ctx, "plugin image resolver source does not implement image resolution", "component", "metadata", "scheme", pluginID, "source_kind", source.kind, "installation_id", source.installationID, "capability_id", source.capabilityID) continue } - slog.Error("plugin batch image resolution failed", + slog.ErrorContext(ctx, "plugin batch image resolution failed", "component", "metadata", "scheme", pluginID, "source_kind", source.kind, "installation_id", source.installationID, @@ -320,7 +320,7 @@ func (r *PluginImageResolver) resolveS3Batch( for _, entry := range entries { url, err := presigner.PresignGetURL(ctx, presigner.Bucket(), entry.originalPath, ttl) if err != nil { - slog.Error("s3 image resolution failed", "path", entry.originalPath, "error", err) + slog.ErrorContext(ctx, "s3 image resolution failed", "component", "metadata", "path", entry.originalPath, "error", err) continue } expiry := expiresAt diff --git a/internal/metadata/person_refresh.go b/internal/metadata/person_refresh.go index 1587d7f6..24ff9889 100644 --- a/internal/metadata/person_refresh.go +++ b/internal/metadata/person_refresh.go @@ -126,7 +126,7 @@ func (s *PersonRefreshService) refreshPersonWithProviders( Language: "en", }) if err != nil { - slog.Warn("person refresh: provider detail lookup failed", + slog.WarnContext(ctx, "person refresh: provider detail lookup failed", "component", "metadata", "provider", provider.Slug(), "person_id", id, "error", err, @@ -207,7 +207,7 @@ func (s *PersonRefreshService) enqueuePersonPhoto(ctx context.Context, person mo ContentType: "people", ImageType: ImageCacheImageProfile, }); err != nil { - slog.Warn("person refresh: failed to enqueue photo cache job", + slog.WarnContext(ctx, "person refresh: failed to enqueue photo cache job", "component", "metadata", "person_id", person.ID, "provider", providerID, "error", err, diff --git a/internal/metadata/provider_id_integrity.go b/internal/metadata/provider_id_integrity.go index 1524f13d..20f320d2 100644 --- a/internal/metadata/provider_id_integrity.go +++ b/internal/metadata/provider_id_integrity.go @@ -62,7 +62,7 @@ func (r *ProviderIDIntegrityRepairer) Run(ctx context.Context, batchSize int) (P outcome, err := r.repairDriftRow(ctx, row) if err != nil { stats.Errors++ - slog.Warn("metadata: provider-id drift row repair failed", + slog.WarnContext(ctx, "metadata: provider-id drift row repair failed", "component", "metadata", "content_id", row.ContentID, "item_type", row.ItemType, "status", row.Status, diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 84a5f848..e81c2502 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -632,7 +632,7 @@ func (s *MetadataService) itemLibrariesAgreeOnLanguage(ctx context.Context, cont } languages, err := s.libraryRepo.GetDistinctMetadataLanguagesForItem(ctx, contentID) if err != nil { - slog.Warn("metadata: failed to look up library languages for adoption gate; keeping current stamp", + slog.WarnContext(ctx, "metadata: failed to look up library languages for adoption gate; keeping current stamp", "component", "metadata", "content_id", contentID, "error", err) return false } @@ -691,7 +691,7 @@ func (s *MetadataService) resolveFolderLanguage(ctx context.Context, folderID in } folder, err := s.folderRepo.GetByID(ctx, folderID) if err != nil { - slog.Warn("metadata: failed to look up folder language, defaulting to English", + slog.WarnContext(ctx, "metadata: failed to look up folder language, defaulting to English", "component", "metadata", "folder_id", folderID, "error", err) return "en" } @@ -716,7 +716,7 @@ func (s *MetadataService) resolveAllowedVideoKinds(ctx context.Context, contentI } else if s.libraryRepo != nil { ids, err := s.libraryRepo.GetFolderIDsForItem(ctx, contentID) if err != nil { - slog.Warn("metadata: resolving item libraries for video kinds failed", + slog.WarnContext(ctx, "metadata: resolving item libraries for video kinds failed", "component", "metadata", "content_id", contentID, "error", err) return nil } @@ -932,7 +932,7 @@ func (s *MetadataService) canUseObservedRootForDirectorySidecars( } location, err := s.observedLocationRepo.Get(ctx, folderID, cleanRoot) if err != nil { - slog.Warn("metadata: observed location lookup failed", + slog.WarnContext(ctx, "metadata: observed location lookup failed", "component", "metadata", "folder_id", folderID, "observed_root_path", cleanRoot, "error", err, @@ -1160,11 +1160,11 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques ) { continue } - slog.Warn("metadata: search provider error", + slog.WarnContext(ctx, "metadata: search provider error", "component", "metadata", "provider", p.Slug(), "error", err) continue } - slog.Debug("metadata: provider search result", + slog.DebugContext(ctx, "metadata: provider search result", "component", "metadata", "provider", p.Slug(), "query_title", searchQuery.Title, "query_year", searchQuery.Year, @@ -1172,7 +1172,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques ) for _, result := range results { if searchResultConflictsWithTrustedIDs(accumulatedIDs, result.ProviderIDs) { - slog.Warn("metadata: skipping conflicting search result", + slog.WarnContext(ctx, "metadata: skipping conflicting search result", "component", "metadata", "provider", p.Slug(), "title", req.Hints.Title, "year", req.Hints.Year, @@ -1186,7 +1186,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } candidates := NormalizeCandidates(allResults, contentType) - slog.Debug("metadata: search candidates assembled", + slog.DebugContext(ctx, "metadata: search candidates assembled", "component", "metadata", "query_title", searchQuery.Title, "query_year", searchQuery.Year, "raw_results", len(allResults), @@ -1276,7 +1276,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques ) { continue } - slog.Warn("metadata: refresh search error", + slog.WarnContext(ctx, "metadata: refresh search error", "component", "metadata", "provider", p.Slug(), "error", searchErr) continue } @@ -1351,7 +1351,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques ) { continue } - slog.Warn("metadata: provider error", + slog.WarnContext(ctx, "metadata: provider error", "component", "metadata", "provider", p.Slug(), "error", err) continue } @@ -1382,7 +1382,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques ) { continue } - slog.Warn("metadata: image provider error", + slog.WarnContext(ctx, "metadata: image provider error", "component", "metadata", "provider", p.Slug(), "error", err) continue } @@ -1418,7 +1418,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques ) { continue } - slog.Warn("metadata: season provider error", + slog.WarnContext(ctx, "metadata: season provider error", "component", "metadata", "provider", p.Slug(), "error", err) continue } @@ -1451,7 +1451,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques ) { continue } - slog.Warn("metadata: episode provider error", + slog.WarnContext(ctx, "metadata: episode provider error", "component", "metadata", "provider", p.Slug(), "season", season.SeasonNumber, "error", err) continue } @@ -1469,7 +1469,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques for slug, providerID := range provider404s { if providerID != "" { if err := s.staleIDRepo.Upsert(ctx, req.ContentID, slug, providerID); err != nil { - slog.Warn("metadata: failed to record stale ID", + slog.WarnContext(ctx, "metadata: failed to record stale ID", "component", "metadata", "content_id", req.ContentID, "provider", slug, "provider_id", providerID, "error", err) } } @@ -1497,7 +1497,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques followUpContentID := refreshFollowUpContentID(req.ContentID, result) if s.staleIDRepo != nil && followUpContentID != "" && provider404s != nil { if delErr := s.staleIDRepo.DeleteByContentID(ctx, followUpContentID); delErr != nil { - slog.Warn("metadata: failed to clear stale IDs after refresh", + slog.WarnContext(ctx, "metadata: failed to clear stale IDs after refresh", "component", "metadata", "content_id", followUpContentID, "error", delErr) } for slug, providerID := range provider404s { @@ -1505,7 +1505,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques continue } if upsertErr := s.staleIDRepo.Upsert(ctx, followUpContentID, slug, providerID); upsertErr != nil { - slog.Warn("metadata: failed to persist stale provider ID after partial refresh", + slog.WarnContext(ctx, "metadata: failed to persist stale provider ID after partial refresh", "component", "metadata", "content_id", followUpContentID, "provider", slug, "provider_id", providerID, @@ -1515,7 +1515,7 @@ func (s *MetadataService) processInternal(ctx context.Context, req ProcessReques } if result != nil && strings.TrimSpace(result.ContentID) != "" { if syncErr := s.syncRefreshDebtForItem(ctx, result.ContentID); syncErr != nil { - slog.Warn("metadata: failed to sync refresh debt after successful metadata refresh", + slog.WarnContext(ctx, "metadata: failed to sync refresh debt after successful metadata refresh", "component", "metadata", "content_id", result.ContentID, "error", syncErr) } @@ -1783,7 +1783,7 @@ func (s *MetadataService) mergeAndPersist( } personIDs, err := s.personRepo.BatchFindOrCreate(ctx, persons) if err != nil { - slog.Warn("metadata: batch find/create people failed", "error", err) + slog.WarnContext(ctx, "metadata: batch find/create people failed", "component", "metadata", "error", err) } else { for i := range item.People { item.People[i].Person.ID = personIDs[i] @@ -1797,7 +1797,7 @@ func (s *MetadataService) mergeAndPersist( } } if err := s.itemRepo.ReplacePeople(ctx, contentID, valid); err != nil { - slog.Warn("metadata: failed to replace people", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "metadata: failed to replace people", "component", "metadata", "content_id", contentID, "error", err) } } @@ -1811,7 +1811,7 @@ func (s *MetadataService) mergeAndPersist( filtered := filterVideosByKinds(accumulator.Videos, allowed) if len(filtered) > 0 || mergeMode == MergeReplaceUnlocked { if err := s.videoRepo.ReplaceByContentID(ctx, contentID, itemVideosFromRemote(contentID, filtered)); err != nil { - slog.Warn("metadata: failed to replace item videos", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "metadata: failed to replace item videos", "component", "metadata", "content_id", contentID, "error", err) } } } @@ -1832,7 +1832,7 @@ func (s *MetadataService) mergeAndPersist( if len(seasons) > 0 { s.persistSeasonsAndEpisodes(ctx, item, accumulator.ProviderIDs, canonicalLanguage, req.Language, seasons, episodes, mergeMode) } else if err := s.SynthesizeFallbackEpisodes(ctx, contentID); err != nil { - slog.Warn("metadata: failed to synthesize fallback series structure", + slog.WarnContext(ctx, "metadata: failed to synthesize fallback series structure", "component", "metadata", "content_id", contentID, "error", err) } } @@ -2169,14 +2169,14 @@ func (s *MetadataService) recordRefreshFailure(ctx context.Context, contentID st return } if err := s.itemRepo.IncrementRefreshFailure(ctx, contentID); err != nil && !errors.Is(err, catalog.ErrItemNotFound) { - slog.Warn("metadata: failed to record refresh failure", + slog.WarnContext(ctx, "metadata: failed to record refresh failure", "component", "metadata", "content_id", contentID, "refresh_error", refreshErr, "error", err) return } if err := s.syncRefreshDebtFailure(ctx, contentID, refreshErr, incrementDebtAttempt); err != nil { - slog.Warn("metadata: failed to sync refresh debt after refresh failure", + slog.WarnContext(ctx, "metadata: failed to sync refresh debt after refresh failure", "component", "metadata", "content_id", contentID, "refresh_error", refreshErr, "error", err) @@ -2195,7 +2195,7 @@ func (s *MetadataService) recordRefreshTargetFailure(ctx context.Context, target return } if err := s.syncRefreshDebtTargetFailure(ctx, targetType, contentID, refreshErr, incrementDebtAttempt); err != nil { - slog.Warn("metadata: failed to sync refresh debt after target refresh failure", + slog.WarnContext(ctx, "metadata: failed to sync refresh debt after target refresh failure", "component", "metadata", "target_type", targetType, "content_id", contentID, "refresh_error", refreshErr, @@ -2625,7 +2625,7 @@ func (s *MetadataService) resolveSeriesRefreshProviderIDs(ctx context.Context, s ) { continue } - slog.Warn("metadata: target refresh search error", + slog.WarnContext(ctx, "metadata: target refresh search error", "component", "metadata", "provider", p.Slug(), "error", searchErr) continue } @@ -2670,7 +2670,7 @@ func (s *MetadataService) fetchTargetSeasonResults(ctx context.Context, provider if handleProvider404(nil, providerIDs, p.Slug(), err, "season", seasonNumber) { continue } - slog.Warn("metadata: target season provider error", + slog.WarnContext(ctx, "metadata: target season provider error", "component", "metadata", "provider", p.Slug(), "season", seasonNumber, "error", err) continue } @@ -2704,7 +2704,7 @@ func (s *MetadataService) fetchTargetEpisodeResults(ctx context.Context, provide if handleChildProvider404(p.Slug(), providerIDs, err, "season", seasonNumber) { continue } - slog.Warn("metadata: target episode provider error", + slog.WarnContext(ctx, "metadata: target episode provider error", "component", "metadata", "provider", p.Slug(), "season", seasonNumber, "error", err) continue } @@ -2732,7 +2732,7 @@ func (s *MetadataService) enqueueImageCacheJobs(ctx context.Context, targetKind, enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() if _, err := s.imageCacheJobs.EnqueueBatch(enqueueCtx, inputs); err != nil { - slog.Warn("metadata: failed to enqueue image cache jobs", + slog.WarnContext(ctx, "metadata: failed to enqueue image cache jobs", "component", "metadata", "target_kind", targetKind, "target_id", targetID, "count", len(inputs), "error", err) } } @@ -2826,12 +2826,12 @@ func (s *MetadataService) resolveImageURLForCache(ctx context.Context, path stri return path, true } if s.imageResolver == nil { - slog.Warn("metadata: cannot cache plugin image without resolver", "url", path) + slog.WarnContext(ctx, "metadata: cannot cache plugin image without resolver", "component", "metadata", "url", path) return "", false } resolved := s.imageResolver.ResolveImageURL(ctx, path, "original") if resolved == "" { - slog.Warn("metadata: resolver returned empty URL for image", "url", path) + slog.WarnContext(ctx, "metadata: resolver returned empty URL for image", "component", "metadata", "url", path) return "", false } return resolved, true @@ -3277,7 +3277,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( for _, season := range seasons { existingSeason, err := s.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, season.SeasonNumber) if err != nil && !errors.Is(err, catalog.ErrSeasonNotFound) { - slog.Warn("metadata: failed to load season before upsert", + slog.WarnContext(ctx, "metadata: failed to load season before upsert", "component", "metadata", "series_id", seriesID, "season", season.SeasonNumber, "error", err) continue } @@ -3322,7 +3322,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( } else { sid, genErr := deriveSeasonContentID(seriesID, providerSeason.SeasonNumber) if genErr != nil { - slog.Warn("metadata: failed to generate season id", + slog.WarnContext(ctx, "metadata: failed to generate season id", "component", "metadata", "series_id", seriesID, "season", season.SeasonNumber, "error", genErr) continue } @@ -3334,7 +3334,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( } } if err := s.seasonRepo.Upsert(ctx, dbSeason); err != nil { - slog.Warn("metadata: failed to upsert season", + slog.WarnContext(ctx, "metadata: failed to upsert season", "component", "metadata", "series_id", seriesID, "season", season.SeasonNumber, "error", err) continue } @@ -3343,7 +3343,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( if !isCanonicalWrite && s.seasonLocalizationRepo != nil { existingLoc, locErr := s.seasonLocalizationRepo.Get(ctx, dbSeason.ContentID, language) if locErr != nil { - slog.Warn("metadata: failed to load season localization", + slog.WarnContext(ctx, "metadata: failed to load season localization", "component", "metadata", "series_id", seriesID, "season", season.SeasonNumber, "error", locErr) } loc := buildSeasonLocalizationRecord( @@ -3354,7 +3354,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( mergeMode, ) if err := s.seasonLocalizationRepo.Upsert(ctx, loc); err != nil { - slog.Warn("metadata: failed to upsert season localization", + slog.WarnContext(ctx, "metadata: failed to upsert season localization", "component", "metadata", "series_id", seriesID, "season", season.SeasonNumber, "error", err) } else { addSeasonLocalizationImageJob(dbSeason, loc) @@ -3413,14 +3413,14 @@ func (s *MetadataService) persistSeasonsAndEpisodes( } else { sid, genErr := deriveSeasonContentID(seriesID, ep.SeasonNumber) if genErr != nil { - slog.Warn("metadata: failed to generate implicit season id", + slog.WarnContext(ctx, "metadata: failed to generate implicit season id", "component", "metadata", "series_id", seriesID, "season", ep.SeasonNumber, "error", genErr) continue } seasonModel.ContentID = sid } if err := s.seasonRepo.Upsert(ctx, seasonModel); err != nil { - slog.Warn("metadata: failed to upsert implicit season", + slog.WarnContext(ctx, "metadata: failed to upsert implicit season", "component", "metadata", "series_id", seriesID, "season", ep.SeasonNumber, "error", err) continue } @@ -3434,7 +3434,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( for _, ep := range episodes { existingEpisode, err := s.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, ep.SeasonNumber, ep.EpisodeNumber) if err != nil && !errors.Is(err, catalog.ErrEpisodeNotFound) { - slog.Warn("metadata: failed to load episode before upsert", + slog.WarnContext(ctx, "metadata: failed to load episode before upsert", "component", "metadata", "series_id", seriesID, "season", ep.SeasonNumber, "episode", ep.EpisodeNumber, "error", err) continue } @@ -3485,7 +3485,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( } else { eid, genErr := deriveEpisodeContentID(seriesID, providerEpisode.SeasonNumber, providerEpisode.EpisodeNumber) if genErr != nil { - slog.Warn("metadata: failed to generate episode id", + slog.WarnContext(ctx, "metadata: failed to generate episode id", "component", "metadata", "series_id", seriesID, "season", ep.SeasonNumber, "episode", ep.EpisodeNumber, "error", genErr) continue @@ -3506,7 +3506,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( dbEp.RatingIMDB = &v } if err := s.episodeRepo.Upsert(ctx, dbEp); err != nil { - slog.Warn("metadata: failed to upsert episode", + slog.WarnContext(ctx, "metadata: failed to upsert episode", "component", "metadata", "series_id", seriesID, "season", ep.SeasonNumber, "episode", ep.EpisodeNumber, "error", err) continue @@ -3515,7 +3515,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( if !isCanonicalWrite && s.episodeLocalizationRepo != nil { existingLoc, locErr := s.episodeLocalizationRepo.Get(ctx, dbEp.ContentID, language) if locErr != nil { - slog.Warn("metadata: failed to load episode localization", + slog.WarnContext(ctx, "metadata: failed to load episode localization", "component", "metadata", "series_id", seriesID, "season", ep.SeasonNumber, "episode", ep.EpisodeNumber, "error", locErr) } @@ -3526,7 +3526,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( providerEpisode, mergeMode, )); err != nil { - slog.Warn("metadata: failed to upsert episode localization", + slog.WarnContext(ctx, "metadata: failed to upsert episode localization", "component", "metadata", "series_id", seriesID, "season", ep.SeasonNumber, "episode", ep.EpisodeNumber, "error", err) } @@ -3538,7 +3538,7 @@ func (s *MetadataService) persistSeasonsAndEpisodes( s.enqueueSeriesChildImages(ctx, seriesID, imageJobs) if err := s.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil { - slog.Warn("metadata: failed to ensure series episode links", + slog.WarnContext(ctx, "metadata: failed to ensure series episode links", "component", "metadata", "series_id", seriesID, "error", err) } s.refreshSeriesEpisodeMetadataState(ctx, seriesID, time.Now()) @@ -3712,7 +3712,7 @@ func (s *MetadataService) ensureSeriesEpisodeLinksCore(ctx context.Context, seri return nil } - slog.Info("metadata: entering fallback episode synthesis for incomplete series", "series_id", seriesID) + slog.InfoContext(ctx, "metadata: entering fallback episode synthesis for incomplete series", "component", "metadata", "series_id", seriesID) if err := s.synthesizeFallbackSeriesStructure(ctx, seriesID); err != nil { return err } @@ -3804,7 +3804,7 @@ func (s *MetadataService) linkSeriesFilesToEpisodesWithOptions(ctx context.Conte if item, itemErr := s.itemRepo.GetByID(ctx, seriesID); itemErr == nil { seriesItem = item } else if !errors.Is(itemErr, catalog.ErrItemNotFound) { - slog.Warn("metadata: failed to load series item for air-date episode preference", + slog.WarnContext(ctx, "metadata: failed to load series item for air-date episode preference", "component", "metadata", "series_id", seriesID, "error", itemErr) } @@ -3825,7 +3825,7 @@ func (s *MetadataService) linkSeriesFilesToEpisodesWithOptions(ctx context.Conte selected, ok := selectAirDateEpisodeCandidate(candidates, seriesItem) if !ok { if len(candidates) > 1 { - slog.Warn("metadata: skipped ambiguous air-date episode link", + slog.WarnContext(ctx, "metadata: skipped ambiguous air-date episode link", "component", "metadata", "series_id", seriesID, "file_id", file.ID, "file_path", file.FilePath, @@ -3844,7 +3844,7 @@ func (s *MetadataService) linkSeriesFilesToEpisodesWithOptions(ctx context.Conte if suppressMissing && errors.Is(err, catalog.ErrEpisodeNotFound) { continue } - slog.Warn("metadata: failed to resolve episode for file", + slog.WarnContext(ctx, "metadata: failed to resolve episode for file", "component", "metadata", "series_id", seriesID, "file_id", file.ID, "file_path", file.FilePath, @@ -3856,7 +3856,7 @@ func (s *MetadataService) linkSeriesFilesToEpisodesWithOptions(ctx context.Conte } if err := linker.UpdateEpisodeLink(ctx, file.ID, episode.ContentID, seasonNum, episodeNum); err != nil { - slog.Warn("metadata: failed to link file to episode", + slog.WarnContext(ctx, "metadata: failed to link file to episode", "component", "metadata", "series_id", seriesID, "file_id", file.ID, "file_path", file.FilePath, @@ -3937,7 +3937,7 @@ type episodeLinkHint struct { func (s *MetadataService) updateEpisodeMetadataState(ctx context.Context, seriesID string, incomplete bool, lastCheckedAt *time.Time) { item, err := s.itemRepo.GetByID(ctx, seriesID) if err != nil { - slog.Warn("metadata: failed to load series item for episode metadata state", + slog.WarnContext(ctx, "metadata: failed to load series item for episode metadata state", "component", "metadata", "series_id", seriesID, "error", err) return } @@ -3945,7 +3945,7 @@ func (s *MetadataService) updateEpisodeMetadataState(ctx context.Context, series item.EpisodeMetadataIncomplete = incomplete item.EpisodeMetadataLastCheckedAt = lastCheckedAt if err := s.itemRepo.Upsert(ctx, item); err != nil { - slog.Warn("metadata: failed to update episode metadata state", + slog.WarnContext(ctx, "metadata: failed to update episode metadata state", "component", "metadata", "series_id", seriesID, "error", err) } } @@ -3957,7 +3957,7 @@ func (s *MetadataService) refreshSeriesEpisodeMetadataState(ctx context.Context, episodes, err := s.episodeRepo.ListBySeries(ctx, seriesID) if err != nil { - slog.Warn("metadata: failed to list series episodes for completeness check", + slog.WarnContext(ctx, "metadata: failed to list series episodes for completeness check", "component", "metadata", "series_id", seriesID, "error", err) return } @@ -3968,7 +3968,7 @@ func (s *MetadataService) refreshSeriesEpisodeMetadataState(ctx context.Context, incomplete = true } if err := s.syncVisibleEpisodeRefreshDebt(ctx, episode, now); err != nil { - slog.Warn("metadata: failed to sync episode refresh debt", + slog.WarnContext(ctx, "metadata: failed to sync episode refresh debt", "component", "metadata", "series_id", seriesID, "episode_id", episode.ContentID, "error", err) @@ -4224,7 +4224,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models if s.scannedGroupRepo != nil && contentGroupKey != "" { scannedGroup, err := s.scannedGroupRepo.Get(ctx, folderID, groupKeyVersion, contentGroupKey) if err != nil { - slog.Warn("metadata: scanned group lookup failed", + slog.WarnContext(ctx, "metadata: scanned group lookup failed", "component", "metadata", "folder_id", folderID, "group_key_version", groupKeyVersion, "content_group_key", contentGroupKey, @@ -4260,7 +4260,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models if s.groupOverrideRepo != nil && contentGroupKey != "" { override, err := s.groupOverrideRepo.Get(ctx, folderID, groupKeyVersion, contentGroupKey) if err != nil { - slog.Warn("metadata: group override lookup failed", + slog.WarnContext(ctx, "metadata: group override lookup failed", "component", "metadata", "folder_id", folderID, "group_key_version", groupKeyVersion, "content_group_key", contentGroupKey, @@ -4333,7 +4333,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models // Clear any legacy skipped-root record since we now have provider IDs. if s != nil && s.skippedRootRepo != nil { if err := s.skippedRootRepo.Delete(ctx, folderID, contentRootPath); err != nil { - slog.Warn("metadata: failed to clear skipped root", + slog.WarnContext(ctx, "metadata: failed to clear skipped root", "component", "metadata", "folder_id", folderID, "root_path", observedRootPath, "error", err) @@ -4518,7 +4518,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models // Link file, claim root, and create library membership. if err := s.fileRepo.UpdateContentID(ctx, file.ID, contentID); err != nil { if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil { - slog.Warn("metadata: failed to clean up unlinked skeleton", + slog.WarnContext(ctx, "metadata: failed to clean up unlinked skeleton", "component", "metadata", "content_id", contentID, "file_id", file.ID, "folder_id", folderID, @@ -4529,7 +4529,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models } if _, err := s.folderTypeForSkeleton(ctx, folderID); err != nil { if clearErr := s.fileRepo.UpdateContentID(ctx, file.ID, ""); clearErr != nil { - slog.Warn("metadata: failed to clear file link after disabled skeleton folder", + slog.WarnContext(ctx, "metadata: failed to clear file link after disabled skeleton folder", "component", "metadata", "content_id", contentID, "file_id", file.ID, "folder_id", folderID, @@ -4537,7 +4537,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models ) } if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil { - slog.Warn("metadata: failed to clean up skeleton for disabled folder", + slog.WarnContext(ctx, "metadata: failed to clean up skeleton for disabled folder", "component", "metadata", "content_id", contentID, "file_id", file.ID, "folder_id", folderID, @@ -4548,7 +4548,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models } if err := s.upsertLibraryMembership(ctx, contentID, folderID); err != nil { if clearErr := s.fileRepo.UpdateContentID(ctx, file.ID, ""); clearErr != nil { - slog.Warn("metadata: failed to clear file link after skeleton membership failure", + slog.WarnContext(ctx, "metadata: failed to clear file link after skeleton membership failure", "component", "metadata", "content_id", contentID, "file_id", file.ID, "folder_id", folderID, @@ -4556,7 +4556,7 @@ func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models ) } if cleanupErr := s.deleteCreatedSkeleton(ctx, contentID); cleanupErr != nil { - slog.Warn("metadata: failed to clean up membershipless skeleton", + slog.WarnContext(ctx, "metadata: failed to clean up membershipless skeleton", "component", "metadata", "content_id", contentID, "file_id", file.ID, "folder_id", folderID, @@ -4597,7 +4597,7 @@ func (s *MetadataService) recordSkippedRoot(ctx context.Context, folderID int, r return } if err := s.skippedRootRepo.UpsertObservedFile(ctx, folderID, rootPath, reason, sampleFilePath); err != nil { - slog.Warn("metadata: failed to record skipped root", + slog.WarnContext(ctx, "metadata: failed to record skipped root", "component", "metadata", "folder_id", folderID, "root_path", rootPath, "reason", reason, @@ -4649,7 +4649,7 @@ func (s *MetadataService) claimRoot(ctx context.Context, folderID int, rootPath, return } if err := s.rootClaimRepo.ClaimRoot(ctx, folderID, rootPath, contentID); err != nil { - slog.Warn("metadata: failed to claim root path", + slog.WarnContext(ctx, "metadata: failed to claim root path", "component", "metadata", "folder_id", folderID, "root_path", rootPath, "content_id", contentID, @@ -4689,7 +4689,7 @@ func (s *MetadataService) claimConfirmedSeriesRootOwnership( } seenGroups[claimKey] = struct{}{} if err := s.groupClaimRepo.ClaimGroup(ctx, folderID, file.GroupKeyVersion, groupKey, contentID); err != nil { - slog.Warn("metadata: failed to claim confirmed series group", + slog.WarnContext(ctx, "metadata: failed to claim confirmed series group", "component", "metadata", "folder_id", folderID, "group_key_version", file.GroupKeyVersion, "content_group_key", groupKey, @@ -4859,7 +4859,7 @@ func (s *MetadataService) rebindItemByProviderIDsLocked( if err := s.rebindItemToExistingItem(ctx, sourceContentID, existing.ContentID, allowMatchedSource); err != nil { return "", err } - slog.Info("metadata: rebound skeleton to existing item", + slog.InfoContext(ctx, "metadata: rebound skeleton to existing item", "component", "metadata", "from_content_id", sourceContentID, "to_content_id", existing.ContentID, "item_type", itemType, @@ -5783,7 +5783,7 @@ func (s *MetadataService) cacheItemImages(ctx context.Context, item *models.Medi ImageType: j.field.imageType, }) if err != nil { - slog.Warn("metadata: image cache failed, keeping CDN URL", + slog.WarnContext(ctx, "metadata: image cache failed, keeping CDN URL", "component", "metadata", "url", j.url, "error", err) return } @@ -5907,7 +5907,7 @@ func (s *MetadataService) FetchItemImages(ctx context.Context, providerIDs map[s Language: language, }) if err != nil { - slog.Warn("fetch item images: provider error", + slog.WarnContext(ctx, "fetch item images: provider error", "component", "metadata", "provider", p.Slug(), "error", err) providerErrors[p.Slug()] = err.Error() continue diff --git a/internal/metadata/translation/service.go b/internal/metadata/translation/service.go index a306ab69..792ccfee 100644 --- a/internal/metadata/translation/service.go +++ b/internal/metadata/translation/service.go @@ -175,7 +175,7 @@ func (s *Service) AutoEnqueue(ctx context.Context, itemContentID, language strin } missing, err := s.content.CountMissingFields(ctx, itemContentID, target) if err != nil { - s.logger.Warn("metadata translation: missing-field count failed", + s.logger.WarnContext(ctx, "metadata translation: missing-field count failed", "content_id", itemContentID, "language", target, "error", err) return } @@ -188,7 +188,7 @@ func (s *Service) AutoEnqueue(ctx context.Context, itemContentID, language strin TargetLanguage: target, IncludeChildren: true, }); err != nil { - s.logger.Warn("metadata translation: auto-enqueue failed", + s.logger.WarnContext(ctx, "metadata translation: auto-enqueue failed", "content_id", itemContentID, "language", target, "error", err) } } @@ -304,7 +304,7 @@ func (f field) segmentID() string { func (s *Service) run(ctx context.Context, job *Job) { if err := s.repo.UpdateProgress(ctx, job.ID, jobrunner.StatusRunning, 0, "Loading content", 0, 0); err != nil { - s.logger.Warn("failed to mark metadata translation job running", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to mark metadata translation job running", "job", job.ID, "error", err) } fields, meta, err := s.collectFields(ctx, job) @@ -314,14 +314,14 @@ func (s *Service) run(ctx context.Context, job *Job) { } if len(fields) == 0 { if err := s.repo.CompleteJob(context.WithoutCancel(ctx), job.ID, "Nothing to translate", 0, 0); err != nil { - s.logger.Warn("failed to complete metadata translation job", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to complete metadata translation job", "job", job.ID, "error", err) } return } total := len(fields) if err := s.repo.UpdateProgress(ctx, job.ID, jobrunner.StatusRunning, 0.05, "Translating", 0, total); err != nil { - s.logger.Warn("failed to update metadata translation job", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to update metadata translation job", "job", job.ID, "error", err) } segments := make([]aitranslate.Segment, total) @@ -385,7 +385,7 @@ func (s *Service) run(ctx context.Context, job *Job) { } if err := s.repo.CompleteJob(context.WithoutCancel(ctx), job.ID, "", done, total); err != nil { - s.logger.Warn("failed to complete metadata translation job", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to complete metadata translation job", "job", job.ID, "error", err) } } @@ -610,9 +610,9 @@ func (s *Service) finishWithError(ctx context.Context, job *Job, err error) { msg = "cancelled" } if dbErr := s.repo.FailJob(context.WithoutCancel(ctx), job.ID, status, msg); dbErr != nil { - s.logger.Warn("failed to record metadata translation job failure", "job", job.ID, "error", dbErr) + s.logger.WarnContext(ctx, "failed to record metadata translation job failure", "job", job.ID, "error", dbErr) } if status == jobrunner.StatusFailed { - s.logger.Warn("metadata translation job failed", "job", job.ID, "content_id", job.ContentID, "error", err) + s.logger.WarnContext(ctx, "metadata translation job failed", "job", job.ID, "content_id", job.ContentID, "error", err) } } diff --git a/internal/metadata/worker.go b/internal/metadata/worker.go index d7e4026d..e308d350 100644 --- a/internal/metadata/worker.go +++ b/internal/metadata/worker.go @@ -153,11 +153,11 @@ func (w *MatchWorker) processUnmatched(ctx context.Context) { if w.enableTVSeriesRootQueue && w.seriesClaimer != nil { jobs, err := w.seriesClaimer.Claim(ctx, w.claimBatchSize()) if err != nil { - slog.Error("metadata: failed to claim unmatched series roots", "error", err) + slog.ErrorContext(ctx, "metadata: failed to claim unmatched series roots", "component", "metadata", "error", err) } else if len(jobs) > 0 { - slog.Info("metadata: processing unmatched series roots", "count", len(jobs)) + slog.InfoContext(ctx, "metadata: processing unmatched series roots", "component", "metadata", "count", len(jobs)) if _, err := w.processSeriesRoots(ctx, jobs); err != nil { - slog.Error("metadata: failed to process unmatched series roots", "error", err) + slog.ErrorContext(ctx, "metadata: failed to process unmatched series roots", "component", "metadata", "error", err) } } } @@ -165,23 +165,23 @@ func (w *MatchWorker) processUnmatched(ctx context.Context) { if w.movieClaimer != nil { files, err := w.movieClaimer.Claim(ctx, w.claimBatchSize()) if err != nil { - slog.Error("metadata: failed to claim queued movie files", "error", err) + slog.ErrorContext(ctx, "metadata: failed to claim queued movie files", "component", "metadata", "error", err) } else if len(files) > 0 { - slog.Info("metadata: processing queued movie files", "count", len(files)) + slog.InfoContext(ctx, "metadata: processing queued movie files", "component", "metadata", "count", len(files)) w.processQueuedMovieFiles(ctx, files) } } files, err := w.claimBackgroundFiles(ctx) if err != nil { - slog.Error("metadata: failed to list unmatched files", "error", err) + slog.ErrorContext(ctx, "metadata: failed to list unmatched files", "component", "metadata", "error", err) return } if len(files) == 0 { return } - slog.Info("metadata: processing unmatched files", "count", len(files)) + slog.InfoContext(ctx, "metadata: processing unmatched files", "component", "metadata", "count", len(files)) w.processFiles(ctx, files) } @@ -192,7 +192,7 @@ func (w *MatchWorker) processFile(ctx context.Context, file *models.MediaFile) { defer cancel() if err := w.fileLister.MarkMatchAttempted(stampCtx, file.ID); err != nil { - slog.Warn("metadata: failed to record match attempt", + slog.WarnContext(ctx, "metadata: failed to record match attempt", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err) @@ -204,7 +204,7 @@ func (w *MatchWorker) processFile(ctx context.Context, file *models.MediaFile) { func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *models.MediaFile, folderEnabledCache *sync.Map, deferredSeriesLinks *sync.Map) { if !w.folderEnabled(ctx, file.MediaFolderID, folderEnabledCache) { - slog.Info("metadata: skipping file in disabled library", + slog.InfoContext(ctx, "metadata: skipping file in disabled library", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "folder_id", file.MediaFolderID, @@ -215,7 +215,7 @@ func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *mode // Phase 0: Create skeleton item or find existing. skeleton, err := w.service.createOrFindSkeleton(ctx, file, file.MediaFolderID) if err != nil { - slog.Warn("metadata: skeleton creation failed", + slog.WarnContext(ctx, "metadata: skeleton creation failed", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err) return } @@ -230,7 +230,7 @@ func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *mode } else if skeleton.Type == "series" { // Fallback for callers that don't provide a deferred map. if err := w.service.ensureSeriesEpisodeLinks(ctx, skeleton.ContentID); err != nil { - slog.Warn("metadata: failed to ensure series episode links", + slog.WarnContext(ctx, "metadata: failed to ensure series episode links", "component", "metadata", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath, @@ -246,14 +246,14 @@ func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *mode req := w.buildProcessRequestForGroup(ctx, file, skeleton, nil) result, err := w.service.Process(ctx, req) if err != nil { - slog.Warn("metadata: enrichment failed", + slog.WarnContext(ctx, "metadata: enrichment failed", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err) w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath) // For series items, synthesize fallback episode structure so episodes // are visible even when no provider match was found. if skeleton.Type == "series" { if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil { - slog.Warn("metadata: fallback episode synthesis failed after enrichment error", + slog.WarnContext(ctx, "metadata: fallback episode synthesis failed after enrichment error", "component", "metadata", "content_id", skeleton.ContentID, "error", fbErr) } } @@ -265,7 +265,7 @@ func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *mode // Same fallback synthesis for series when no provider data was returned. if skeleton.Type == "series" { if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil { - slog.Warn("metadata: fallback episode synthesis failed for unmatched series", + slog.WarnContext(ctx, "metadata: fallback episode synthesis failed for unmatched series", "component", "metadata", "content_id", skeleton.ContentID, "error", fbErr) } } @@ -281,7 +281,7 @@ func (w *MatchWorker) buildProcessRequestForGroup(ctx context.Context, represent if skeleton != nil && skeleton.Type == "series" && w.service != nil && w.service.fileRepo != nil { loadedFiles, err := w.service.fileRepo.ListByObservedRootPath(ctx, representative.MediaFolderID, skeleton.ObservedRootPath) if err != nil { - slog.Warn("metadata: failed to load observed-root files", + slog.WarnContext(ctx, "metadata: failed to load observed-root files", "component", "metadata", "folder_id", representative.MediaFolderID, "observed_root_path", skeleton.ObservedRootPath, "error", err, @@ -451,7 +451,7 @@ func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folde if err != nil { return 0, err } - slog.Info("metadata: scoped matcher selected", + slog.InfoContext(ctx, "metadata: scoped matcher selected", "component", "metadata", "folder_id", folderID, "path_prefix", pathPrefix, "matcher_path", scopedMatcherPath(useSeriesQueue, useMovieQueue), @@ -547,7 +547,7 @@ func (w *MatchWorker) processFiles(ctx context.Context, files []*models.MediaFil } contentID := key.(string) if err := w.service.ensureSeriesEpisodeLinks(ctx, contentID); err != nil { - slog.Warn("metadata: deferred series episode link failed", + slog.WarnContext(ctx, "metadata: deferred series episode link failed", "component", "metadata", "content_id", contentID, "error", err) } return true @@ -567,7 +567,7 @@ func (w *MatchWorker) isMatchSuppressed(ctx context.Context, file *models.MediaF } suppressed, err := checker.IsMatchSuppressed(ctx, file.ID) if err != nil { - slog.Warn("metadata: failed to check match suppression", + slog.WarnContext(ctx, "metadata: failed to check match suppression", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err, @@ -575,7 +575,7 @@ func (w *MatchWorker) isMatchSuppressed(ctx context.Context, file *models.MediaF return true } if suppressed { - slog.Info("metadata: skipping suppressed unmatched file", + slog.InfoContext(ctx, "metadata: skipping suppressed unmatched file", "component", "metadata", "file_id", file.ID, "path", file.FilePath, ) @@ -628,13 +628,13 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M if err != nil { queueErr := truncateSeriesQueueError(err.Error()) if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, queueErr); updateErr != nil { - slog.Warn("metadata: failed to update movie queue error", + slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", updateErr, ) } - slog.Warn("metadata: movie queue skeleton creation failed", + slog.WarnContext(ctx, "metadata: movie queue skeleton creation failed", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err, @@ -648,7 +648,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M // future enqueues (see movieQueueFileEligibleCond), so this drains rows // claimed before the skipped root was recorded. if err := w.movieClaimer.Delete(ctx, file.ID); err != nil { - slog.Warn("metadata: failed to delete skipped movie queue row", + slog.WarnContext(ctx, "metadata: failed to delete skipped movie queue row", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err, @@ -659,7 +659,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M } if skeleton == nil || strings.TrimSpace(skeleton.ContentID) == "" { if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, truncateSeriesQueueError("movie queue claimed without a content id")); updateErr != nil { - slog.Warn("metadata: failed to update movie queue error", + slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", updateErr, @@ -669,7 +669,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M } if skeleton.ItemStatus == "ambiguous" { if err := w.movieClaimer.Delete(ctx, file.ID); err != nil { - slog.Warn("metadata: failed to delete ambiguous movie queue row", + slog.WarnContext(ctx, "metadata: failed to delete ambiguous movie queue row", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err, @@ -685,13 +685,13 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M if processErr != nil { queueErr := truncateSeriesQueueError(processErr.Error()) if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, queueErr); updateErr != nil { - slog.Warn("metadata: failed to update movie queue error", + slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", updateErr, ) } - slog.Warn("metadata: enrichment failed", + slog.WarnContext(ctx, "metadata: enrichment failed", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", processErr, @@ -700,7 +700,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M return false } else if result != nil && !result.Updated { if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil { - slog.Warn("metadata: failed to update movie queue error", + slog.WarnContext(ctx, "metadata: failed to update movie queue error", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", updateErr, @@ -713,7 +713,7 @@ func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.M } if err := w.movieClaimer.Delete(ctx, file.ID); err != nil { - slog.Warn("metadata: failed to delete movie queue row", + slog.WarnContext(ctx, "metadata: failed to delete movie queue row", "component", "metadata", "file_id", file.ID, "path", file.FilePath, "error", err, @@ -868,7 +868,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil { return 0, err } - slog.Info("metadata: series root dropped because files disappeared", + slog.InfoContext(ctx, "metadata: series root dropped because files disappeared", "component", "metadata", "folder_id", job.MediaFolderID, "observed_root_path", job.ObservedRootPath, ) @@ -892,7 +892,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil { return 0, updateErr } - slog.Warn("metadata: enrichment failed", + slog.WarnContext(ctx, "metadata: enrichment failed", "component", "metadata", "file_id", representative.ID, "path", representative.FilePath, "error", processErr) @@ -901,7 +901,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo "file_id", representative.ID, "path", representative.FilePath) if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil { - slog.Warn("metadata: fallback episode synthesis failed after series root enrichment error", + slog.WarnContext(ctx, "metadata: fallback episode synthesis failed after series root enrichment error", "component", "metadata", "content_id", skeleton.ContentID, "error", fbErr) } return 0, nil @@ -915,7 +915,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo "file_id", representative.ID, "path", representative.FilePath) if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil { - slog.Warn("metadata: fallback episode synthesis failed for unmatched series root", + slog.WarnContext(ctx, "metadata: fallback episode synthesis failed for unmatched series root", "component", "metadata", "content_id", skeleton.ContentID, "error", fbErr) } return 0, nil @@ -928,7 +928,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo // dedup moves its seasons+episodes to the survivor, then deletes the // source row). The episodes are already reattached, so there is nothing // to link here — benign; finish normally instead of failing the batch. - slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping", + slog.InfoContext(ctx, "metadata: series item gone during episode-link ensure (likely concurrent merge); skipping", "component", "metadata", "content_id", representative.ContentID, "folder_id", job.MediaFolderID, "observed_root_path", job.ObservedRootPath) @@ -952,7 +952,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil { return 0, updateErr } - slog.Warn("metadata: series root skeleton creation failed", + slog.WarnContext(ctx, "metadata: series root skeleton creation failed", "component", "metadata", "folder_id", job.MediaFolderID, "observed_root_path", job.ObservedRootPath, "sample_file_path", job.SampleFilePath, @@ -979,7 +979,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil { return 0, updateErr } - slog.Warn("metadata: enrichment failed", + slog.WarnContext(ctx, "metadata: enrichment failed", "component", "metadata", "file_id", representative.ID, "path", representative.FilePath, "error", processErr) @@ -991,7 +991,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo "observed_root_path", job.ObservedRootPath, ) if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil { - slog.Warn("metadata: fallback episode synthesis failed after enrichment error", + slog.WarnContext(ctx, "metadata: fallback episode synthesis failed after enrichment error", "component", "metadata", "content_id", skeleton.ContentID, "error", fbErr, ) @@ -1009,7 +1009,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo "observed_root_path", job.ObservedRootPath, ) if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil { - slog.Warn("metadata: fallback episode synthesis failed for unmatched series", + slog.WarnContext(ctx, "metadata: fallback episode synthesis failed for unmatched series", "component", "metadata", "content_id", skeleton.ContentID, "error", fbErr, ) @@ -1032,7 +1032,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo if strings.TrimSpace(finalContentID) != "" { if err := w.service.ensureSeriesEpisodeLinks(ctx, finalContentID); err != nil { if errors.Is(err, catalog.ErrItemNotFound) { - slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping", + slog.InfoContext(ctx, "metadata: series item gone during episode-link ensure (likely concurrent merge); skipping", "component", "metadata", "content_id", finalContentID, "folder_id", job.MediaFolderID, "observed_root_path", job.ObservedRootPath) @@ -1052,7 +1052,7 @@ func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRo return 0, err } - slog.Info("metadata: series root completed", + slog.InfoContext(ctx, "metadata: series root completed", "component", "metadata", "folder_id", job.MediaFolderID, "observed_root_path", job.ObservedRootPath, "content_id", finalContentID, @@ -1067,7 +1067,7 @@ func (w *MatchWorker) logStatusUpdateFailure(ctx context.Context, contentID, sta } if err := w.service.updateItemStatus(ctx, contentID, status); err != nil { args := append([]any{"content_id", contentID, "status", status, "error", err}, attrs...) - slog.Warn("metadata: failed to update item status", args...) + slog.WarnContext(ctx, "metadata: failed to update item status", append([]any{"component", "metadata"}, args...)...) } } @@ -1088,7 +1088,7 @@ func (w *MatchWorker) collapseClaimedSeriesBatch(ctx context.Context, files []*m if !ok { folder, err := w.service.folderRepo.GetByID(ctx, file.MediaFolderID) if err != nil { - slog.Warn("metadata: failed to load folder type for batch compaction", + slog.WarnContext(ctx, "metadata: failed to load folder type for batch compaction", "component", "metadata", "folder_id", file.MediaFolderID, "file_id", file.ID, "error", err, @@ -1136,7 +1136,7 @@ func (w *MatchWorker) folderEnabled(ctx context.Context, folderID int, cache *sy enabled := true folder, err := w.service.folderRepo.GetByID(ctx, folderID) if err != nil { - slog.Warn("metadata: failed to load folder state during match", + slog.WarnContext(ctx, "metadata: failed to load folder state during match", "component", "metadata", "folder_id", folderID, "error", err, ) @@ -1299,7 +1299,7 @@ func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Conte }) if processErr != nil { stillUnmatched++ - slog.Warn("metadata: scoped retry failed", + slog.WarnContext(ctx, "metadata: scoped retry failed", "component", "metadata", "content_id", contentID, "folder_id", folderID, "path_prefix", pathPrefix, @@ -1317,7 +1317,7 @@ func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Conte return retried, stillUnmatched, repairErr } else if repaired > 0 { retried += repaired - slog.Info("metadata: repaired matched duplicate items in scope", + slog.InfoContext(ctx, "metadata: repaired matched duplicate items in scope", "component", "metadata", "folder_id", folderID, "path_prefix", pathPrefix, "repaired_items", repaired, @@ -1340,7 +1340,7 @@ func (w *MatchWorker) publishCatalogItemChanged(ctx context.Context, libraryID i ContentID: contentID, Change: change, }); err != nil { - slog.Warn("metadata: failed to publish catalog item change", + slog.WarnContext(ctx, "metadata: failed to publish catalog item change", "component", "metadata", "content_id", contentID, "library_id", libraryID, "change", change, diff --git a/internal/nodeconfig/watcher.go b/internal/nodeconfig/watcher.go index b20f9307..ebc070b2 100644 --- a/internal/nodeconfig/watcher.go +++ b/internal/nodeconfig/watcher.go @@ -89,7 +89,7 @@ func (w *Watcher) Start(ctx context.Context) error { } } }); err != nil { - slog.Warn("config watcher: subscribe to admin channel failed, using poll-only mode", "error", err) + slog.WarnContext(ctx, "config watcher: subscribe to admin channel failed, using poll-only mode", "component", "nodeconfig", "error", err) } go w.poll(ctx) @@ -217,11 +217,11 @@ func (w *Watcher) poll(ctx context.Context) { return case <-ticker.C: if err := w.reload(ctx); err != nil { - slog.Warn("config poll reload failed", "error", err) + slog.WarnContext(ctx, "config poll reload failed", "component", "nodeconfig", "error", err) } case <-w.reloadCh: if err := w.reload(ctx); err != nil { - slog.Warn("config event reload failed", "error", err) + slog.WarnContext(ctx, "config event reload failed", "component", "nodeconfig", "error", err) } } } diff --git a/internal/nodepool/health.go b/internal/nodepool/health.go index e1f9d027..41126317 100644 --- a/internal/nodepool/health.go +++ b/internal/nodepool/health.go @@ -93,14 +93,14 @@ func (hc *HealthChecker) checkAll(ctx context.Context) { applyHealth(n.ID, healthy, activeJobs, egressKbps, time.Now()) if n.Healthy && !healthy { - slog.Warn("stream node unhealthy", "id", n.ID, "name", n.Name, "url", n.URL) + slog.WarnContext(ctx, "stream node unhealthy", "component", "nodepool", "id", n.ID, "name", n.Name, "url", n.URL) } else if !n.Healthy && healthy { - slog.Info("stream node recovered", "id", n.ID, "name", n.Name, "url", n.URL) + slog.InfoContext(ctx, "stream node recovered", "component", "nodepool", "id", n.ID, "name", n.Name, "url", n.URL) } if hc.repo != nil { if err := hc.repo.UpdateHealth(ctx, n.ID, healthy, activeJobs, egressKbps); err != nil { - slog.Error("failed to persist node health", "id", n.ID, "error", err) + slog.ErrorContext(ctx, "failed to persist node health", "component", "nodepool", "id", n.ID, "error", err) } } }) diff --git a/internal/noderecipe/store.go b/internal/noderecipe/store.go index 26d194a3..4210d4ca 100644 --- a/internal/noderecipe/store.go +++ b/internal/noderecipe/store.go @@ -79,13 +79,13 @@ func (s *Store) Get(ctx context.Context, sessionID string) (*playback.RecipeCard data, err := s.rdb.Get(ctx, key(sessionID)).Bytes() if err != nil { if !errors.Is(err, redis.Nil) { - slog.Warn("load node recipe failed", "error", err, "playback_session_id", sessionID) + slog.WarnContext(ctx, "load node recipe failed", "component", "noderecipe", "error", err, "playback_session_id", sessionID) } return nil, false } var card playback.RecipeCard if err := json.Unmarshal(data, &card); err != nil { - slog.Warn("decode node recipe failed", "error", err, "playback_session_id", sessionID) + slog.WarnContext(ctx, "decode node recipe failed", "component", "noderecipe", "error", err, "playback_session_id", sessionID) return nil, false } return &card, true diff --git a/internal/nodesessions/tracker.go b/internal/nodesessions/tracker.go index 773cf124..cac36b12 100644 --- a/internal/nodesessions/tracker.go +++ b/internal/nodesessions/tracker.go @@ -116,12 +116,12 @@ func (tr *Tracker) Track(ctx context.Context, info SessionInfo) { } data, err := json.Marshal(info) if err != nil { - slog.Debug("session track marshal failed", "error", err) + slog.DebugContext(ctx, "session track marshal failed", "component", "nodesessions", "error", err) return } key := tr.redisKey(info.SessionID) if err := tr.rdb.Set(ctx, key, data, sessionTTL).Err(); err != nil { - slog.Debug("session track set failed", "error", err, "session", info.SessionID) + slog.DebugContext(ctx, "session track set failed", "component", "nodesessions", "error", err, "session", info.SessionID) return } @@ -148,11 +148,11 @@ func (tr *Tracker) Touch(ctx context.Context, info SessionInfo) { data, err := json.Marshal(info) if err != nil { - slog.Debug("session touch marshal failed", "error", err) + slog.DebugContext(ctx, "session touch marshal failed", "component", "nodesessions", "error", err) return } if err := tr.rdb.Set(ctx, tr.redisKey(info.SessionID), data, sessionTTL).Err(); err != nil { - slog.Debug("session touch set failed", "error", err, "session", info.SessionID) + slog.DebugContext(ctx, "session touch set failed", "component", "nodesessions", "error", err, "session", info.SessionID) } } @@ -167,7 +167,7 @@ func (tr *Tracker) Remove(ctx context.Context, sessionID string) { tr.mu.Unlock() if err := tr.rdb.Del(ctx, tr.redisKey(sessionID)).Err(); err != nil { - slog.Debug("session remove failed", "error", err, "session", sessionID) + slog.DebugContext(ctx, "session remove failed", "component", "nodesessions", "error", err, "session", sessionID) } } @@ -199,7 +199,7 @@ func (tr *Tracker) Cleanup(ctx context.Context) { pipe.Del(ctx, tr.redisKey(id)) } if _, err := pipe.Exec(ctx); err != nil { - slog.Debug("session cleanup pipeline failed", "error", err) + slog.DebugContext(ctx, "session cleanup pipeline failed", "component", "nodesessions", "error", err) } } @@ -252,6 +252,6 @@ func (tr *Tracker) refreshAll(ctx context.Context) { pipe.Expire(ctx, tr.redisKey(id), sessionTTL) } if _, err := pipe.Exec(ctx); err != nil { - slog.Debug("session refresh pipeline failed", "error", err) + slog.DebugContext(ctx, "session refresh pipeline failed", "component", "nodesessions", "error", err) } } diff --git a/internal/notifications/account_channel_engine.go b/internal/notifications/account_channel_engine.go index a1e81f0e..7db1da57 100644 --- a/internal/notifications/account_channel_engine.go +++ b/internal/notifications/account_channel_engine.go @@ -270,7 +270,7 @@ func (w *accountChannelWorker[K]) Run(ctx context.Context) { func (w *accountChannelWorker[K]) runPass(ctx context.Context) { recipients, err := w.channel.listRecipients(ctx) if err != nil { - w.logger.Error("channel pass: list recipients failed", "error", err) + w.logger.ErrorContext(ctx, "channel pass: list recipients failed", "error", err) return } if len(recipients) == 0 { @@ -301,7 +301,7 @@ func (w *accountChannelWorker[K]) runPass(ctx context.Context) { pending, err := w.channel.hasPendingSince(ctx, rec.Key, Cursor{CreatedAt: rec.WatermarkCreatedAt, ID: rec.WatermarkID}) if err != nil { - w.logger.Warn("channel pass: pending check failed", "recipient", rec.Key, "error", err) + w.logger.WarnContext(ctx, "channel pass: pending check failed", "recipient", rec.Key, "error", err) continue } if !pending { @@ -316,7 +316,7 @@ func (w *accountChannelWorker[K]) runPass(ctx context.Context) { pending, err := w.channel.hasTransactionalPendingSince(ctx, rec.Key, Cursor{CreatedAt: rec.WatermarkCreatedAt, ID: rec.WatermarkID}) if err != nil { - w.logger.Warn("channel pass: transactional pending check failed", + w.logger.WarnContext(ctx, "channel pass: transactional pending check failed", "recipient", rec.Key, "error", err) continue } @@ -332,7 +332,7 @@ func (w *accountChannelWorker[K]) runPass(ctx context.Context) { return // channel turned off mid-pass; nothing else will send either } failures++ - w.logger.Warn("channel send failed", "recipient", rec.Key, "mode", mode, "error", err) + w.logger.WarnContext(ctx, "channel send failed", "recipient", rec.Key, "mode", mode, "error", err) } } } @@ -492,7 +492,7 @@ func (w *accountChannelWorker[K]) processRecipient(ctx context.Context, rec acco } return err } - w.logger.Info("notification sent", + w.logger.InfoContext(ctx, "notification sent", "recipient", rec.Key, "mode", mode, "items", len(items)) } diff --git a/internal/notifications/availability_detector.go b/internal/notifications/availability_detector.go index 315da658..647fec12 100644 --- a/internal/notifications/availability_detector.go +++ b/internal/notifications/availability_detector.go @@ -108,7 +108,7 @@ func (d *AvailabilityDetector) flatKindOps(k flatItemKind) availabilityKindOps { func (d *AvailabilityDetector) runKind(ctx context.Context, libraryID int, fullLibrary bool, scopePaths []string, ops availabilityKindOps) { seeded, err := d.releases.IsContentSeeded(ctx, libraryID, ops.kind) if err != nil { - d.logger.Warn("seed state lookup failed", + d.logger.WarnContext(ctx, "seed state lookup failed", "library_id", libraryID, "kind", ops.kind, "error", err) return } @@ -126,23 +126,23 @@ func (d *AvailabilityDetector) runKind(ctx context.Context, libraryID int, fullL inserted, events, err = ops.recordForPaths(ctx, libraryID, scopePaths, false) } if err != nil { - d.logger.Warn("availability detection failed", + d.logger.WarnContext(ctx, "availability detection failed", "library_id", libraryID, "kind", ops.kind, "error", err) return } if fullLibrary && !seeded { if err := d.releases.MarkContentSeeded(ctx, libraryID, ops.kind); err != nil { - d.logger.Warn("seed marker write failed", + d.logger.WarnContext(ctx, "seed marker write failed", "library_id", libraryID, "kind", ops.kind, "error", err) } else { - d.logger.Info("library availability seeded", + d.logger.InfoContext(ctx, "library availability seeded", "library_id", libraryID, "kind", ops.kind, "availability_rows", inserted) } } if inserted > 0 || events > 0 { - d.logger.Info("availability recorded", + d.logger.InfoContext(ctx, "availability recorded", "library_id", libraryID, "kind", ops.kind, "full_library", fullLibrary, diff --git a/internal/notifications/channel_dispatcher.go b/internal/notifications/channel_dispatcher.go index db01dcc4..d47c5d5b 100644 --- a/internal/notifications/channel_dispatcher.go +++ b/internal/notifications/channel_dispatcher.go @@ -34,8 +34,8 @@ func (d *channelDispatcher[A]) dispatch(deliveryID string) { select { case d.queue <- deliveryID: default: - d.logger.Warn(d.channel+" dispatch queue full; deferring to retry worker", - "delivery_id", deliveryID) + d.logger.Warn("dispatch queue full; deferring to retry worker", + "channel", d.channel, "delivery_id", deliveryID) } } @@ -72,7 +72,7 @@ func (d *channelDispatcher[A]) processDelivery(ctx context.Context, deliveryID s attempts, err := d.claimPending(ctx, deliveryID) if err != nil { if ctx.Err() == nil { - d.logger.Warn(d.channel+" attempt claim failed", "delivery_id", deliveryID, "error", err) + d.logger.WarnContext(ctx, "attempt claim failed", "channel", d.channel, "delivery_id", deliveryID, "error", err) } return } @@ -111,7 +111,7 @@ func runRetrySweep[A any]( attempts, err := claim(ctx, limit) if err != nil { if ctx.Err() == nil { - logger.Warn(channel+" retry claim failed", "error", err) + logger.WarnContext(ctx, "retry claim failed", "channel", channel, "error", err) } break } diff --git a/internal/notifications/discord_dm.go b/internal/notifications/discord_dm.go index da6be59d..1f2630fc 100644 --- a/internal/notifications/discord_dm.go +++ b/internal/notifications/discord_dm.go @@ -248,7 +248,7 @@ func (s *System) CompleteDiscordLink(ctx context.Context, userID int, code, redi if err := s.DiscordPrefs.SetIdentity(ctx, userID, user.ID, user.Username); err != nil { return discord.User{}, err } - s.logger.Info("discord account linked", "user_id", userID, "discord_user_id", user.ID) + s.logger.InfoContext(ctx, "discord account linked", "user_id", userID, "discord_user_id", user.ID) return user, nil } diff --git a/internal/notifications/fanout_worker.go b/internal/notifications/fanout_worker.go index 2f262561..556489ce 100644 --- a/internal/notifications/fanout_worker.go +++ b/internal/notifications/fanout_worker.go @@ -123,7 +123,7 @@ func (w *FanoutWorker) Run(ctx context.Context) { processed, err := w.processBatch(ctx) if err != nil { if ctx.Err() == nil { - w.logger.Error("fanout batch failed", "error", err) + w.logger.ErrorContext(ctx, "fanout batch failed", "error", err) } break } @@ -220,12 +220,12 @@ func (w *FanoutWorker) processBatch(ctx context.Context) (int, error) { // Failures here are tolerable: the durable rows appear on reconnect. for _, row := range dispatchRows { if err := w.dispatcher.Dispatch(ctx, row); err != nil { - w.logger.Warn("notification dispatch failed", + w.logger.WarnContext(ctx, "notification dispatch failed", "delivery_id", row.ID, "profile_id", row.ProfileID, "error", err) } } - w.logger.Info("fanout batch processed", + w.logger.InfoContext(ctx, "fanout batch processed", "claimed", len(claimed), "non_episode", len(others), "fanned_out", len(fanout), @@ -457,7 +457,7 @@ func (w *FanoutWorker) enqueueWebhookOutbox(ctx context.Context, tx pgx.Tx, inse } } if rateLimited > 0 { - w.logger.Warn("webhook deliveries rate limited", "skipped", rateLimited) + w.logger.WarnContext(ctx, "webhook deliveries rate limited", "skipped", rateLimited) } return w.webhooks.EnqueueAttempts(ctx, tx, attempts) } diff --git a/internal/notifications/interest_hooks.go b/internal/notifications/interest_hooks.go index 6f222fe1..807bfbb6 100644 --- a/internal/notifications/interest_hooks.go +++ b/internal/notifications/interest_hooks.go @@ -264,7 +264,7 @@ func (s *interestTrackingStore) DeleteProfile(ctx context.Context, id string) er purgeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() if purgeErr := s.system.PurgeProfile(purgeCtx, id); purgeErr != nil { - slog.Warn("notifications: profile purge failed", "profile_id", id, "error", purgeErr) + slog.WarnContext(ctx, "notifications: profile purge failed", "component", "notifications", "profile_id", id, "error", purgeErr) } } return err diff --git a/internal/notifications/interest_updater.go b/internal/notifications/interest_updater.go index 3811e18c..33be6a51 100644 --- a/internal/notifications/interest_updater.go +++ b/internal/notifications/interest_updater.go @@ -134,7 +134,7 @@ func (u *InterestUpdater) flush(ctx context.Context) { } seriesID, ok, err := u.resolveSeriesID(ctx, mutation.itemID) if err != nil { - u.logger.Warn("interest series resolution failed", + u.logger.WarnContext(ctx, "interest series resolution failed", "item_id", mutation.itemID, "error", err) requeue[mutation] = failures + 1 continue @@ -152,7 +152,7 @@ func (u *InterestUpdater) flush(ctx context.Context) { err = u.RecomputeSeries(recomputeCtx, mutation.userID, mutation.profileID, seriesID) cancel() if err != nil { - u.logger.Warn("interest recompute failed", + u.logger.WarnContext(ctx, "interest recompute failed", "user_id", mutation.userID, "profile_id", mutation.profileID, "series_id", seriesID, "error", err) requeue[mutation] = failures + 1 @@ -165,7 +165,7 @@ func (u *InterestUpdater) flush(ctx context.Context) { u.mu.Lock() for mutation, failures := range requeue { if failures >= interestMaxFlushAttempts { - u.logger.Warn("interest mutation dropped after repeated failures", + u.logger.WarnContext(ctx, "interest mutation dropped after repeated failures", "item_id", mutation.itemID, "profile_id", mutation.profileID) continue } diff --git a/internal/notifications/operational_dispatch.go b/internal/notifications/operational_dispatch.go index 9edfe275..081ebbff 100644 --- a/internal/notifications/operational_dispatch.go +++ b/internal/notifications/operational_dispatch.go @@ -97,12 +97,12 @@ func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opt // websocket reconnect, and the retry workers recover the outbox rows. full, err := s.Deliveries.GetRowByID(ctx, row.ID) if err != nil || full == nil { - s.logger.Warn("operational delivery reload failed", + s.logger.WarnContext(ctx, "operational delivery reload failed", "delivery_id", row.ID, "error", err) return &row, nil } if err := s.dispatcher.Dispatch(ctx, *full); err != nil { - s.logger.Warn("operational delivery dispatch failed", + s.logger.WarnContext(ctx, "operational delivery dispatch failed", "delivery_id", row.ID, "error", err) } return &row, nil diff --git a/internal/notifications/operational_dispatch_test.go b/internal/notifications/operational_dispatch_test.go index 43e09741..d054677e 100644 --- a/internal/notifications/operational_dispatch_test.go +++ b/internal/notifications/operational_dispatch_test.go @@ -2,7 +2,6 @@ package notifications import ( "context" - "io" "log/slog" "os" "testing" @@ -113,7 +112,7 @@ func TestDispatchOperationalEnqueuesApplePushAttempts(t *testing.T) { Deliveries: NewDeliveryRepository(pool), pushDeviceRepo: NewPushDeviceRepository(pool), dispatcher: NewMultiDispatcher(), - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + logger: slog.New(slog.DiscardHandler), } inserted, err := system.DispatchOperational(ctx, Delivery{ diff --git a/internal/notifications/push_sender.go b/internal/notifications/push_sender.go index 77cd0449..b1c62950 100644 --- a/internal/notifications/push_sender.go +++ b/internal/notifications/push_sender.go @@ -97,7 +97,7 @@ func (s *pushSender) processAttempt(ctx context.Context, attempt PushDeliveryAtt return s.finalize(ctx, attempt, PushOutcomeFailed, "", "push device deleted", nil, "", nil) } if ctx.Err() == nil { - s.logger.Warn("push device lookup failed", "attempt_id", attempt.ID, "error", err) + s.logger.WarnContext(ctx, "push device lookup failed", "attempt_id", attempt.ID, "error", err) } return nil } @@ -108,7 +108,7 @@ func (s *pushSender) processAttempt(ctx context.Context, attempt PushDeliveryAtt row, err := s.deliveries.GetRowByID(ctx, *attempt.NotificationDeliveryID) if err != nil { if ctx.Err() == nil { - s.logger.Warn("push delivery lookup failed", + s.logger.WarnContext(ctx, "push delivery lookup failed", "attempt_id", attempt.ID, "delivery_id", *attempt.NotificationDeliveryID, "error", err) @@ -164,7 +164,7 @@ func (s *pushSender) finalize(ctx context.Context, attempt PushDeliveryAttempt, attemptNumber := attempt.AttemptNumber + 1 updated, err := s.devices.FinalizePushAttempt(ctx, attempt.ID, outcome, attemptNumber, relayRequestID, statusPtr, reason, message, nextRetryAt) if err != nil && ctx.Err() == nil { - s.logger.Warn("finalize push attempt failed", "attempt_id", attempt.ID, "error", err) + s.logger.WarnContext(ctx, "finalize push attempt failed", "attempt_id", attempt.ID, "error", err) } return updated } diff --git a/internal/notifications/request_notifier.go b/internal/notifications/request_notifier.go index b25090c0..1aa5d91b 100644 --- a/internal/notifications/request_notifier.go +++ b/internal/notifications/request_notifier.go @@ -170,7 +170,7 @@ func (n *RequestLifecycleNotifier) dispatchPersonal(ctx context.Context, req req go func() { defer cancel() if err := n.system.dispatchRequestLifecycle(dispatchCtx, req, deliveryType); err != nil { - n.system.logger.Warn("request lifecycle delivery failed", + n.system.logger.WarnContext(ctx, "request lifecycle delivery failed", "request_id", req.ID, "type", deliveryType, "error", err) } }() diff --git a/internal/notifications/server_channel_request_notifier.go b/internal/notifications/server_channel_request_notifier.go index a5c3ad2b..af029249 100644 --- a/internal/notifications/server_channel_request_notifier.go +++ b/internal/notifications/server_channel_request_notifier.go @@ -62,7 +62,7 @@ func (s *System) requesterDiscordID(ctx context.Context, userID int) string { } prefs, err := s.DiscordPrefs.Get(ctx, userID) if err != nil { - s.logger.Warn("server channel request post: discord identity lookup failed", + s.logger.WarnContext(ctx, "server channel request post: discord identity lookup failed", "user_id", userID, "error", err) return "" } diff --git a/internal/notifications/server_channel_worker.go b/internal/notifications/server_channel_worker.go index a46af4f6..722beb34 100644 --- a/internal/notifications/server_channel_worker.go +++ b/internal/notifications/server_channel_worker.go @@ -105,7 +105,7 @@ func (w *serverChannelWorker) Run(ctx context.Context) { func (w *serverChannelWorker) runPass(ctx context.Context) { channels, err := w.repo.ListEnabledForContent(ctx) if err != nil { - w.logger.Error("server channel pass: list channels failed", "error", err) + w.logger.ErrorContext(ctx, "server channel pass: list channels failed", "error", err) return } if len(channels) == 0 { @@ -128,7 +128,7 @@ func (w *serverChannelWorker) runPass(ctx context.Context) { pending, err := w.releases.HasEventsSince(ctx, Cursor{CreatedAt: ch.WatermarkCreatedAt, ID: ch.WatermarkID}, batchAge) if err != nil { - w.logger.Warn("server channel pass: pending check failed", "channel_id", ch.ID, "error", err) + w.logger.WarnContext(ctx, "server channel pass: pending check failed", "channel_id", ch.ID, "error", err) continue } if !pending { @@ -137,7 +137,7 @@ func (w *serverChannelWorker) runPass(ctx context.Context) { sent, err := w.processChannel(ctx, ch.ID, batchAge) if err != nil { failures++ - w.logger.Warn("server channel sweep failed", "channel_id", ch.ID, "error", err) + w.logger.WarnContext(ctx, "server channel sweep failed", "channel_id", ch.ID, "error", err) continue } if sent { @@ -237,7 +237,7 @@ func (w *serverChannelWorker) processChannel(ctx context.Context, channelID stri if err := tx.Commit(ctx); err != nil { return false, err } - w.logger.Info("server channel content posted", + w.logger.InfoContext(ctx, "server channel content posted", "channel_id", ch.ID, "url_host", ch.URLHost, "events", len(fresh), "groups", len(groups)) return true, nil @@ -339,7 +339,7 @@ func (w *serverChannelWorker) PostRequestEvent(ctx context.Context, event string } channels, err := w.requestEventChannels(ctx) if err != nil { - w.logger.Warn("server channel request post: list channels failed", "error", err) + w.logger.WarnContext(ctx, "server channel request post: list channels failed", "error", err) return } // Request posters are raw TMDB paths rendered by the Discord builder; @@ -371,7 +371,7 @@ func (w *serverChannelWorker) PostRequestEvent(ctx context.Context, event string result := w.sender.sendRequest(ctx, &ch, event, info) if result.OK { if err := w.repo.RecordSendSuccess(ctx, ch.ID); err != nil { - w.logger.Warn("server channel success bookkeeping failed", "channel_id", ch.ID, "error", err) + w.logger.WarnContext(ctx, "server channel success bookkeeping failed", "channel_id", ch.ID, "error", err) } continue } @@ -380,9 +380,9 @@ func (w *serverChannelWorker) PostRequestEvent(ctx context.Context, event string status = &result.HTTPStatus } if err := w.repo.RecordSendFailure(ctx, ch.ID, status, result.Message); err != nil { - w.logger.Warn("server channel failure bookkeeping failed", "channel_id", ch.ID, "error", err) + w.logger.WarnContext(ctx, "server channel failure bookkeeping failed", "channel_id", ch.ID, "error", err) } - w.logger.Warn("server channel request post failed", + w.logger.WarnContext(ctx, "server channel request post failed", "channel_id", ch.ID, "event", event, "status", result.HTTPStatus, "message", result.Message) } } diff --git a/internal/notifications/system.go b/internal/notifications/system.go index 1933c28b..0eae1482 100644 --- a/internal/notifications/system.go +++ b/internal/notifications/system.go @@ -372,7 +372,7 @@ func (s *System) Start(ctx context.Context) { go func() { defer s.wg.Done() if _, err := s.WebPush.PublicKey(ctx); err != nil && ctx.Err() == nil { - s.logger.Error("web push VAPID provisioning failed", "error", err) + s.logger.ErrorContext(ctx, "web push VAPID provisioning failed", "error", err) } }() } @@ -519,7 +519,7 @@ func (s *System) SeedAvailability(ctx context.Context, progress func(percent int report(passBase+(i+1)*passSpan/max(len(libraryIDs), 1), fmt.Sprintf("Seeded library %d %s availability (%d new rows)", libraryID, pass.kind, inserted)) } - s.logger.Info("availability seeding completed", + s.logger.InfoContext(ctx, "availability seeding completed", "kind", pass.kind, "libraries", len(libraryIDs), "availability_rows", totalSeeded) } report(100, "Availability seeding completed") @@ -568,12 +568,12 @@ func (s *System) RebuildInterest(ctx context.Context, progress func(percent int, } store, err := s.stores.ForUser(ctx, user.ID) if err != nil { - s.logger.Warn("interest rebuild: open user store failed", "user_id", user.ID, "error", err) + s.logger.WarnContext(ctx, "interest rebuild: open user store failed", "user_id", user.ID, "error", err) continue } profiles, err := store.ListProfiles(ctx) if err != nil { - s.logger.Warn("interest rebuild: list profiles failed", "user_id", user.ID, "error", err) + s.logger.WarnContext(ctx, "interest rebuild: list profiles failed", "user_id", user.ID, "error", err) continue } sort.Slice(profiles, func(i, j int) bool { return profiles[i].ID < profiles[j].ID }) @@ -587,7 +587,7 @@ func (s *System) RebuildInterest(ctx context.Context, progress func(percent int, if ctx.Err() != nil { return ctx.Err() } - s.logger.Warn("interest rebuild: profile failed", + s.logger.WarnContext(ctx, "interest rebuild: profile failed", "user_id", user.ID, "profile_id", profile.ID, "error", err) } if err := s.saveBackfillCheckpoint(ctx, interestRebuildTask, key); err != nil { @@ -602,7 +602,7 @@ func (s *System) RebuildInterest(ctx context.Context, progress func(percent int, if err := s.completeBackfillCheckpoint(ctx, interestRebuildTask); err != nil { return err } - s.logger.Info("interest rebuild completed", "profiles", processed) + s.logger.InfoContext(ctx, "interest rebuild completed", "profiles", processed) return nil } @@ -687,7 +687,7 @@ func (s *System) rebuildProfileInterest(ctx context.Context, store userstore.Use return ctx.Err() } if err := s.Interest.RecomputeSeries(ctx, userID, profileID, seriesID); err != nil { - s.logger.Warn("interest rebuild: series recompute failed", + s.logger.WarnContext(ctx, "interest rebuild: series recompute failed", "user_id", userID, "profile_id", profileID, "series_id", seriesID, "error", err) } } diff --git a/internal/notifications/webhook_sender.go b/internal/notifications/webhook_sender.go index 25ce0177..dbfa63fb 100644 --- a/internal/notifications/webhook_sender.go +++ b/internal/notifications/webhook_sender.go @@ -174,7 +174,7 @@ func (s *webhookSender) processAttempt(ctx context.Context, attempt DeliveryAtte // lease expires and the retry worker reclaims it, instead of // permanently failing the delivery over a database blip. if ctx.Err() == nil { - s.logger.Warn("webhook delivery lookup failed", + s.logger.WarnContext(ctx, "webhook delivery lookup failed", "attempt_id", attempt.ID, "delivery_id", attempt.NotificationDeliveryID, "error", err) @@ -194,7 +194,7 @@ func (s *webhookSender) processAttempt(ctx context.Context, attempt DeliveryAtte _ = s.webhooks.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeDelivered, attemptNumber, &result.HTTPStatus, "", nil) if err := s.webhooks.RecordSuccess(ctx, hook.ID); err != nil { - s.logger.Warn("webhook success bookkeeping failed", "webhook_id", hook.ID, "error", err) + s.logger.WarnContext(ctx, "webhook success bookkeeping failed", "webhook_id", hook.ID, "error", err) } return } @@ -204,7 +204,7 @@ func (s *webhookSender) processAttempt(ctx context.Context, attempt DeliveryAtte status = &result.HTTPStatus } if err := s.webhooks.RecordFailure(ctx, hook.ID, status, result.Message); err != nil { - s.logger.Warn("webhook failure bookkeeping failed", "webhook_id", hook.ID, "error", err) + s.logger.WarnContext(ctx, "webhook failure bookkeeping failed", "webhook_id", hook.ID, "error", err) } if result.HTTPStatus > 0 && !retryableHTTPStatus(result.HTTPStatus) { @@ -238,7 +238,7 @@ func (s *webhookSender) processAttempt(ctx context.Context, attempt DeliveryAtte func (s *webhookSender) maybeDisableAfter4xx(ctx context.Context, hook *Webhook, result webhookSendResult) { recent, err := s.webhooks.RecentFinalOutcomes(ctx, hook.ID, autoDisableConsecutive4xx) if err != nil { - s.logger.Warn("webhook 4xx history lookup failed", "webhook_id", hook.ID, "error", err) + s.logger.WarnContext(ctx, "webhook 4xx history lookup failed", "webhook_id", hook.ID, "error", err) return } if len(recent) < autoDisableConsecutive4xx { @@ -263,10 +263,10 @@ func (s *webhookSender) disableWebhook(ctx context.Context, hook *Webhook, resul fullReason = fmt.Sprintf("%s (last error: %s)", reason, result.Message) } if err := s.webhooks.Disable(ctx, hook.ID, fullReason); err != nil { - s.logger.Error("webhook auto-disable failed", "webhook_id", hook.ID, "error", err) + s.logger.ErrorContext(ctx, "webhook auto-disable failed", "webhook_id", hook.ID, "error", err) return } - s.logger.Warn("webhook auto-disabled", + s.logger.WarnContext(ctx, "webhook auto-disabled", "webhook_id", hook.ID, "url_host", hook.URLHost, "reason", fullReason) noticeFlags, err := json.Marshal(map[string]any{ @@ -290,7 +290,7 @@ func (s *webhookSender) disableWebhook(ctx context.Context, hook *Webhook, resul // Nil WebhookFilter: the notice must never re-dispatch as a webhook, or a // broken webhook would loop forever. if _, err := s.operational(ctx, notice, OperationalDispatch{}); err != nil { - s.logger.Warn("webhook auto-disable notice dispatch failed", "webhook_id", hook.ID, "error", err) + s.logger.WarnContext(ctx, "webhook auto-disable notice dispatch failed", "webhook_id", hook.ID, "error", err) } } diff --git a/internal/notifications/webpush_sender.go b/internal/notifications/webpush_sender.go index af8dc1cf..ca317dcd 100644 --- a/internal/notifications/webpush_sender.go +++ b/internal/notifications/webpush_sender.go @@ -120,7 +120,7 @@ func (s *webPushSender) processAttempt(ctx context.Context, attempt DeliveryAtte // Transient lookup failure: let the claim lease expire and the retry // worker reclaim, instead of permanently failing the delivery. if ctx.Err() == nil { - s.logger.Warn("web push delivery lookup failed", + s.logger.WarnContext(ctx, "web push delivery lookup failed", "attempt_id", attempt.ID, "delivery_id", attempt.NotificationDeliveryID, "error", err) @@ -142,7 +142,7 @@ func (s *webPushSender) processAttempt(ctx context.Context, attempt DeliveryAtte publicKey, privateKey, err := s.service.vapidKeys(ctx) if err != nil { - s.logger.Error("VAPID keys unavailable", "error", err) + s.logger.ErrorContext(ctx, "VAPID keys unavailable", "error", err) _ = s.subscriptions.FinalizeAttempt(ctx, attempt.ID, WebhookOutcomeFailed, attempt.AttemptNumber+1, nil, "VAPID keys unavailable", nil) return @@ -172,7 +172,7 @@ func (s *webPushSender) processAttempt(ctx context.Context, attempt DeliveryAtte if status == http.StatusNotFound || status == http.StatusGone { // The browser unsubscribed or the registration expired: remove the // subscription entirely (attempts cascade with it). - s.logger.Info("web push subscription gone; removing", + s.logger.InfoContext(ctx, "web push subscription gone; removing", "subscription_id", sub.ID, "status", status) _ = s.subscriptions.deleteGone(ctx, sub.ID) return diff --git a/internal/opslog/cleanup.go b/internal/opslog/cleanup.go index 270c85cb..8d92c947 100644 --- a/internal/opslog/cleanup.go +++ b/internal/opslog/cleanup.go @@ -186,13 +186,13 @@ func LoadCleanupInterval(ctx context.Context, store SettingsStore) time.Duration func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, pm PartitionManager) int64 { policy, err := LoadRetentionPolicy(ctx, store) if err != nil { - slog.Warn("opslog cleanup policy error", "error", err) + slog.WarnContext(ctx, "opslog cleanup policy error", "component", "opslog", "error", err) policy = DefaultRetentionPolicy() } if pm != nil { if err := pm.EnsureFuturePartitions(ctx); err != nil { - slog.Warn("opslog ensure future partitions error", "error", err) + slog.WarnContext(ctx, "opslog ensure future partitions error", "component", "opslog", "error", err) } } @@ -206,22 +206,22 @@ func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, p if pm != nil { partitionCleanupFailed := false if dropped, err := pm.DropExpiredPartitions(ctx, cutoff); err != nil { - slog.Warn("opslog partition cleanup error", "error", err) + slog.WarnContext(ctx, "opslog partition cleanup error", "component", "opslog", "error", err) partitionCleanupFailed = true } else if len(dropped) > 0 { - slog.Info("opslog dropped expired partitions", "partitions", dropped) + slog.InfoContext(ctx, "opslog dropped expired partitions", "component", "opslog", "partitions", dropped) } if deleted, err := pm.DeleteExpiredRowsFromDefault(ctx, cutoff); err != nil { - slog.Warn("opslog default partition cleanup error", "error", err) + slog.WarnContext(ctx, "opslog default partition cleanup error", "component", "opslog", "error", err) partitionCleanupFailed = true } else { totalDeleted += deleted if deleted > 0 { - slog.Info("opslog default partition cleanup completed", "deleted", deleted) + slog.InfoContext(ctx, "opslog default partition cleanup completed", "component", "opslog", "deleted", deleted) } } if partitionCleanupFailed { - slog.Warn("opslog partition cleanup degraded, falling back to row deletes", "retention_days", policy.Global.RetentionDays) + slog.WarnContext(ctx, "opslog partition cleanup degraded, falling back to row deletes", "component", "opslog", "retention_days", policy.Global.RetentionDays) totalDeleted += pruneByAgeBefore(ctx, pool, cutoff, "", "") } } else { @@ -231,8 +231,8 @@ func CleanupOnce(ctx context.Context, pool *pgxpool.Pool, store SettingsStore, p totalDeleted += pruneBySizeCap(ctx, pool, policy.Global.MaxSizeMB, "", "") if totalDeleted > 0 { - slog.Info( - "opslog cleanup completed", + slog.InfoContext(ctx, + "opslog cleanup completed", "component", "opslog", "deleted", totalDeleted, "retention_days", policy.Global.RetentionDays, "max_rows", policy.Global.MaxRows, @@ -267,7 +267,10 @@ func pruneByAgeBefore(ctx context.Context, pool *pgxpool.Pool, cutoff time.Time, result, err := pool.Exec(ctx, query, args...) if err != nil { - slog.Warn("opslog age cleanup error", "error", err, "component", component, "level", level) + // target_component/target_level describe the retention-policy scope + // being pruned; the plain "component" key is reserved for routing + // the log line itself (see Handler.Handle). + slog.WarnContext(ctx, "opslog age cleanup error", "component", "opslog", "error", err, "target_component", component, "target_level", level) return totalDeleted } deleted := result.RowsAffected() @@ -315,7 +318,7 @@ func pruneByRowCap(ctx context.Context, pool *pgxpool.Pool, maxRows int64, compo LIMIT $%d )`, nextArg2, filters2, nextArg2+1), args2...) if err != nil { - slog.Warn("opslog row-cap cleanup error", "error", err, "component", component, "level", level) + slog.WarnContext(ctx, "opslog row-cap cleanup error", "component", "opslog", "error", err, "target_component", component, "target_level", level) return totalDeleted } deleted := result.RowsAffected() @@ -403,7 +406,7 @@ func pruneBySizeCap(ctx context.Context, pool *pgxpool.Pool, maxSizeMB int64, co LIMIT $%d )`, nextArg4, filters4, nextArg4+1), args4...) if err != nil { - slog.Warn("opslog size-cap cleanup error", "error", err, "component", component, "level", level) + slog.WarnContext(ctx, "opslog size-cap cleanup error", "component", "opslog", "error", err, "target_component", component, "target_level", level) return totalDeleted } deleted := result.RowsAffected() diff --git a/internal/opslog/consumer.go b/internal/opslog/consumer.go index affb1f1b..b7c5bc22 100644 --- a/internal/opslog/consumer.go +++ b/internal/opslog/consumer.go @@ -63,14 +63,14 @@ func (c *Consumer) drainRedis(ctx context.Context) { for { entries, err := c.popRedisBatch(ctx) if err != nil { - slog.Warn("opslog: Redis pop error", "error", err) + slog.WarnContext(ctx, "opslog: Redis pop error", "component", "opslog", "error", err) return } if len(entries) == 0 { return } if err := c.insertBatchWithRetry(ctx, entries); err != nil { - slog.Error("opslog: batch insert failed after retries, dropping batch", "error", err, "count", len(entries)) + slog.ErrorContext(ctx, "opslog: batch insert failed after retries, dropping batch", "component", "opslog", "error", err, "count", len(entries)) } } } @@ -88,7 +88,7 @@ func (c *Consumer) popRedisBatch(ctx context.Context) ([]Entry, error) { for _, raw := range result { var entry Entry if err := json.Unmarshal([]byte(raw), &entry); err != nil { - slog.Warn("opslog: skipping malformed entry", "error", err) + slog.WarnContext(ctx, "opslog: skipping malformed entry", "component", "opslog", "error", err) continue } entries = append(entries, entry) @@ -101,7 +101,7 @@ func (c *Consumer) insertBatchWithRetry(ctx context.Context, entries []Entry) er for attempt := 0; attempt < c.maxRetries; attempt++ { if err := c.insertBatch(ctx, entries); err != nil { lastErr = err - slog.Warn("opslog: batch insert attempt failed", "attempt", attempt+1, "error", err) + slog.WarnContext(ctx, "opslog: batch insert attempt failed", "component", "opslog", "attempt", attempt+1, "error", err) time.Sleep(time.Duration(attempt+1) * time.Second) continue } @@ -121,7 +121,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan Entry) { case <-ctx.Done(): if len(batch) > 0 { if err := c.insertBatch(context.Background(), batch); err != nil { - slog.Warn("opslog batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "opslog batch insert failed", "component", "opslog", "entries", len(batch), "error", err) } } return @@ -129,7 +129,7 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan Entry) { if !ok { if len(batch) > 0 { if err := c.insertBatch(context.Background(), batch); err != nil { - slog.Warn("opslog batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "opslog batch insert failed", "component", "opslog", "entries", len(batch), "error", err) } } return @@ -137,14 +137,14 @@ func (c *Consumer) RunMemory(ctx context.Context, ch <-chan Entry) { batch = append(batch, entry) if len(batch) >= c.batchSize { if err := c.insertBatch(ctx, batch); err != nil { - slog.Warn("opslog batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "opslog batch insert failed", "component", "opslog", "entries", len(batch), "error", err) } batch = batch[:0] } case <-ticker.C: if len(batch) > 0 { if err := c.insertBatch(ctx, batch); err != nil { - slog.Warn("opslog batch insert failed", "entries", len(batch), "error", err) + slog.WarnContext(ctx, "opslog batch insert failed", "component", "opslog", "entries", len(batch), "error", err) } batch = batch[:0] } @@ -215,7 +215,7 @@ func (c *Consumer) insertBatch(ctx context.Context, entries []Entry) error { for _, entry := range inserted { if err := c.streamHub.PublishAppend(ctx, logstream.StreamApp, entry); err != nil { - slog.Warn("opslog: failed to publish log stream append", "error", err, "id", entry.ID) + slog.WarnContext(ctx, "opslog: failed to publish log stream append", "component", "opslog", "error", err, "id", entry.ID) } } diff --git a/internal/opslog/handler.go b/internal/opslog/handler.go index 7980ab2b..84e7bcd1 100644 --- a/internal/opslog/handler.go +++ b/internal/opslog/handler.go @@ -5,6 +5,8 @@ import ( "log/slog" "strings" "time" + + "github.com/Silo-Server/silo-server/internal/logredact" ) type Handler struct { @@ -185,14 +187,10 @@ func redactAttrs(attrs map[string]any) map[string]any { return redacted } +// shouldRedact delegates to the shared logredact detection so the DB path and +// the console/file/OTLP sinks stay in lock-step on what counts as a secret key. func shouldRedact(key string) bool { - key = strings.ToLower(key) - for _, marker := range []string{"password", "secret", "token", "api_key", "apikey", "authorization", "cookie"} { - if strings.Contains(key, marker) { - return true - } - } - return false + return logredact.SecretKey(key) } func cloneMap(src map[string]any) map[string]any { diff --git a/internal/playback/chapter_thumbnail_notifier.go b/internal/playback/chapter_thumbnail_notifier.go index a9f6d0f0..2ea10c91 100644 --- a/internal/playback/chapter_thumbnail_notifier.go +++ b/internal/playback/chapter_thumbnail_notifier.go @@ -67,8 +67,8 @@ func (n *ChapterThumbnailNotifier) ChapterThumbnailReady( n.ttl, ) if err != nil { - slog.Warn( - "failed to presign chapter thumbnail for realtime event", + slog.WarnContext(ctx, + "failed to presign chapter thumbnail for realtime event", "component", "playback", "file_id", fileID, "chapter_index", @@ -91,8 +91,8 @@ func (n *ChapterThumbnailNotifier) ChapterThumbnailReady( thumbnailThumbhash, ) if err != nil { - slog.Warn( - "failed to encode chapter thumbnail realtime event", + slog.WarnContext(ctx, + "failed to encode chapter thumbnail realtime event", "component", "playback", "session_id", session.ID, "file_id", @@ -105,8 +105,8 @@ func (n *ChapterThumbnailNotifier) ChapterThumbnailReady( continue } if err := n.hub.Send(session.ID, event); err != nil && !errors.Is(err, ErrRealtimeConnectionNotFound) { - slog.Warn( - "failed to deliver chapter thumbnail realtime event", + slog.WarnContext(ctx, + "failed to deliver chapter thumbnail realtime event", "component", "playback", "session_id", session.ID, "file_id", diff --git a/internal/playback/ffmpeg_log_sink.go b/internal/playback/ffmpeg_log_sink.go index 787dfa17..fb634c6b 100644 --- a/internal/playback/ffmpeg_log_sink.go +++ b/internal/playback/ffmpeg_log_sink.go @@ -81,7 +81,7 @@ func (s *SlogFFmpegLogSink) WriteEvent(ctx context.Context, sessionID string, at if attrs.DroppedLines > 0 { args = append(args, ffmpegDroppedLinesKey, attrs.DroppedLines) } - s.logger.InfoContext(ctx, message, args...) + s.logger.InfoContext(ctx, "ffmpeg event", args...) } func (s *SlogFFmpegLogSink) baseArgs(sessionID string, attrs FFmpegLogAttrs) []any { diff --git a/internal/playback/marker_update_notifier.go b/internal/playback/marker_update_notifier.go index 5fd57afb..0b3d8157 100644 --- a/internal/playback/marker_update_notifier.go +++ b/internal/playback/marker_update_notifier.go @@ -28,7 +28,7 @@ func NewMarkerUpdateNotifier(sessions markerUpdateSessionLookup, hub *RealtimeHu } } -func (n *MarkerUpdateNotifier) MarkersUpdated(_ context.Context, file *models.MediaFile) { +func (n *MarkerUpdateNotifier) MarkersUpdated(ctx context.Context, file *models.MediaFile) { if n == nil || file == nil || file.ID <= 0 { return } @@ -50,8 +50,8 @@ func (n *MarkerUpdateNotifier) MarkersUpdated(_ context.Context, file *models.Me } event, err := NewMarkersUpdatedEvent(session.ID, file.ID, intro, credits, recap, preview) if err != nil { - slog.Warn( - "failed to encode markers updated realtime event", + slog.WarnContext(ctx, + "failed to encode markers updated realtime event", "component", "playback", "session_id", session.ID, "file_id", @@ -62,8 +62,8 @@ func (n *MarkerUpdateNotifier) MarkersUpdated(_ context.Context, file *models.Me continue } if err := n.hub.Send(session.ID, event); err != nil && !errors.Is(err, ErrRealtimeConnectionNotFound) { - slog.Warn( - "failed to deliver markers updated realtime event", + slog.WarnContext(ctx, + "failed to deliver markers updated realtime event", "component", "playback", "session_id", session.ID, "file_id", diff --git a/internal/playback/session.go b/internal/playback/session.go index e61d00ec..9a4ca674 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -321,7 +321,7 @@ func (m *SessionManager) StartSessionWithFilesContext( if err != nil { // Fail closed, but make an engine outage distinguishable from a // genuine concurrency-limit denial in the logs. - slog.Warn("playback admission decider error; denying session", + slog.WarnContext(ctx, "playback admission decider error; denying session", "component", "playback", "user_id", userID, "method", method, "error", err) return nil, admissionDenyError("") } diff --git a/internal/playback/subtitle_ready_notifier.go b/internal/playback/subtitle_ready_notifier.go index 7ca0a324..0c6ba906 100644 --- a/internal/playback/subtitle_ready_notifier.go +++ b/internal/playback/subtitle_ready_notifier.go @@ -32,7 +32,7 @@ func NewSubtitleReadyNotifier(sessions subtitleReadySessionLookup, hub *Realtime // SubtitleReady notifies active sessions for the file that a new subtitle track // with the given downloaded-subtitle ID is available. -func (n *SubtitleReadyNotifier) SubtitleReady(_ context.Context, mediaFileID, subtitleID int, language, label string) { +func (n *SubtitleReadyNotifier) SubtitleReady(ctx context.Context, mediaFileID, subtitleID int, language, label string) { if n == nil || mediaFileID <= 0 || subtitleID <= 0 { return } @@ -43,40 +43,40 @@ func (n *SubtitleReadyNotifier) SubtitleReady(_ context.Context, mediaFileID, su } event, err := NewSubtitleReadyEvent(session.ID, mediaFileID, subtitleID, language, label) if err != nil { - slog.Warn("failed to encode subtitle ready realtime event", + slog.WarnContext(ctx, "failed to encode subtitle ready realtime event", "component", "playback", "session_id", session.ID, "file_id", mediaFileID, "subtitle_id", subtitleID, "error", err) continue } if err := n.hub.Send(session.ID, event); err != nil && !errors.Is(err, ErrRealtimeConnectionNotFound) { - slog.Warn("failed to deliver subtitle ready realtime event", + slog.WarnContext(ctx, "failed to deliver subtitle ready realtime event", "component", "playback", "session_id", session.ID, "file_id", mediaFileID, "subtitle_id", subtitleID, "error", err) } } } // TranslationStarted tells one session a live translation has begun. -func (n *SubtitleReadyNotifier) TranslationStarted(_ context.Context, sessionID string, fileID int, jobID int64, trackKey, language, label string, totalCues int) { +func (n *SubtitleReadyNotifier) TranslationStarted(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey, language, label string, totalCues int) { n.sendTranslation(sessionID, func() (EventEnvelope, error) { return NewSubtitleTranslationStartedEvent(sessionID, fileID, jobID, trackKey, language, label, totalCues) }) } // TranslationCues pushes a batch of translated cues to one session. -func (n *SubtitleReadyNotifier) TranslationCues(_ context.Context, sessionID string, fileID int, jobID int64, trackKey string, cues []StreamCue, done, total int) { +func (n *SubtitleReadyNotifier) TranslationCues(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey string, cues []StreamCue, done, total int) { n.sendTranslation(sessionID, func() (EventEnvelope, error) { return NewSubtitleTranslationCuesEvent(sessionID, fileID, jobID, trackKey, cues, done, total) }) } // TranslationCompleted tells one session a live translation finished. -func (n *SubtitleReadyNotifier) TranslationCompleted(_ context.Context, sessionID string, fileID int, jobID int64, trackKey string, subtitleID int, language, label string) { +func (n *SubtitleReadyNotifier) TranslationCompleted(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey string, subtitleID int, language, label string) { n.sendTranslation(sessionID, func() (EventEnvelope, error) { return NewSubtitleTranslationCompletedEvent(sessionID, fileID, jobID, trackKey, subtitleID, language, label) }) } // TranslationFailed tells one session a live translation failed. -func (n *SubtitleReadyNotifier) TranslationFailed(_ context.Context, sessionID string, fileID int, jobID int64, trackKey, message string) { +func (n *SubtitleReadyNotifier) TranslationFailed(ctx context.Context, sessionID string, fileID int, jobID int64, trackKey, message string) { n.sendTranslation(sessionID, func() (EventEnvelope, error) { return NewSubtitleTranslationFailedEvent(sessionID, fileID, jobID, trackKey, message) }) diff --git a/internal/playback/subtitle_stream.go b/internal/playback/subtitle_stream.go index 39a147e3..5af32c5d 100644 --- a/internal/playback/subtitle_stream.go +++ b/internal/playback/subtitle_stream.go @@ -85,7 +85,7 @@ func StreamExtractSubtitle(ctx context.Context, opts StreamExtractOpts) error { copyErr := copyAndFlush(opts.Writer, stdout) waitErr := cmd.Wait() - slog.Debug("subtitle stream extract finished", + slog.DebugContext(ctx, "subtitle stream extract finished", "component", "playback", "track", opts.TrackIndex, "seek", opts.SeekSeconds, "elapsed_ms", time.Since(start).Milliseconds(), @@ -213,7 +213,7 @@ func LogSubtitleStreamError(ctx context.Context, err error, fileID, trackIndex i // Normal client disconnect mid-stream — don't warn. return } - slog.Warn("subtitle stream extract failed", + slog.WarnContext(ctx, "subtitle stream extract failed", "component", "playback", "file_id", fileID, "track", trackIndex, "error", err, diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go index 12b8ef00..e6681d9a 100644 --- a/internal/playback/transcode_manager.go +++ b/internal/playback/transcode_manager.go @@ -334,7 +334,7 @@ func (m *TranscodeManager) ReconstructSession(ctx context.Context, sessionID str // mismatches the card owner is refused. Either way the reconstructed session is // bound to card.UserID, never to the request's user. if requestUserID != 0 && requestUserID != card.UserID { - slog.Warn("transcode reconstruct ownership rejected", + slog.WarnContext(ctx, "transcode reconstruct ownership rejected", "component", "playback", "session", sessionID, "playback_session_id", sessionID, "request_user", requestUserID, "card_user", card.UserID) return nil @@ -378,7 +378,7 @@ func (m *TranscodeManager) ReconstructSession(ctx context.Context, sessionID str // transcode limit) must still refuse: a replayed token cannot reconstruct // past the cap a fresh StartSession would enforce. if errors.Is(err, ErrTooManyStreams) || errors.Is(err, ErrTooManyTranscodes) { - slog.Warn("playback session reconstruct refused by admission cap", + slog.WarnContext(ctx, "playback session reconstruct refused by admission cap", "component", "playback", "session", sessionID, "playback_session_id", sessionID, "user", card.UserID, "method", method, "error", err) return nil @@ -389,12 +389,12 @@ func (m *TranscodeManager) ReconstructSession(ctx context.Context, sessionID str // collapse a recoverable dependency error into a permanent 404 and stop // playback for a user who is within their limits. The cap will re-apply on // the next fresh StartSession once the provider recovers. - slog.Warn("playback session reconstruct admitting despite unevaluated limits (degraded; limit provider unavailable)", + slog.WarnContext(ctx, "playback session reconstruct admitting despite unevaluated limits (degraded; limit provider unavailable)", "component", "playback", "session", sessionID, "playback_session_id", sessionID, "user", card.UserID, "method", method, "error", err) session = m.Sessions.RegisterReconstructed(s) } - slog.Info("playback session reconstructed from recipe card", + slog.InfoContext(ctx, "playback session reconstructed from recipe card", "component", "playback", "session", sessionID, "playback_session_id", sessionID, "user", card.UserID, "method", method) return session } @@ -550,7 +550,7 @@ func (m *TranscodeManager) doReconstructTranscode(ctx context.Context, sessionID transcodeSession, err := StartTranscode(context.WithoutCancel(ctx), opts) slotRelease() if err != nil { - slog.Error("reconstruct transcode start failed", "error", err, "session", sessionID, "playback_session_id", sessionID) + slog.ErrorContext(ctx, "reconstruct transcode start failed", "component", "playback", "error", err, "session", sessionID, "playback_session_id", sessionID) return nil } @@ -581,7 +581,7 @@ func (m *TranscodeManager) doReconstructTranscode(ctx context.Context, sessionID m.StartThrottler(ctx, transcodeSession) } m.MonitorLocalTranscodeExit(sessionID, transcodeSession) - slog.Info("transcode process reconstructed from recipe card", + slog.InfoContext(ctx, "transcode process reconstructed from recipe card", "component", "playback", "session", sessionID, "playback_session_id", sessionID, "requested_segment", requestedSegment, "start_segment_number", opts.StartSegmentNumber) return transcodeSession diff --git a/internal/plugins/archive_cache.go b/internal/plugins/archive_cache.go index e5f1ac82..8e8519a2 100644 --- a/internal/plugins/archive_cache.go +++ b/internal/plugins/archive_cache.go @@ -128,8 +128,10 @@ func (c *ArchiveCache) recoverLegacyBinaryArchive( // fail startup over a write error — the in-memory archive is already valid // and recovery will retry on the next preload. if err := c.archives.SaveArchive(ctx, installationID, manifestBytes, manifest.GetChecksum(), archiveBytes); err != nil { - slog.Warn( + slog.WarnContext( + ctx, "failed to persist recovered legacy plugin archive; will retry on next preload", + "component", "plugins", "installation_id", installationID, "error", err, ) diff --git a/internal/plugins/auto_update.go b/internal/plugins/auto_update.go index d7a51fdd..210f4467 100644 --- a/internal/plugins/auto_update.go +++ b/internal/plugins/auto_update.go @@ -249,11 +249,11 @@ func (s *AutoUpdateService) Run(ctx context.Context) error { AutoInstallDefaults: true, }) if err != nil { - s.logger.Warn("failed to run plugin auto-update", "error", err) + s.logger.WarnContext(ctx, "failed to run plugin auto-update", "error", err) return nil } for _, failure := range summary.Failures { - s.logger.Warn("plugin auto-update operation failed", "error", failure) + s.logger.WarnContext(ctx, "plugin auto-update operation failed", "error", failure) } return nil } @@ -279,7 +279,7 @@ func (s *AutoUpdateService) seedDefaultRepository(ctx context.Context) (bool, er return false, err } - s.logger.Info("seeded default plugin repository", + s.logger.InfoContext(ctx, "seeded default plugin repository", "url", DefaultRepositoryURL, "name", DefaultRepositoryName, ) @@ -293,7 +293,7 @@ func (s *AutoUpdateService) handleNewPlugin(ctx context.Context, pluginID string } version := entry.Manifest.GetVersion() - s.logger.Info("auto-installing default plugin", + s.logger.InfoContext(ctx, "auto-installing default plugin", "plugin_id", pluginID, "version", version, ) @@ -371,7 +371,7 @@ func (s *AutoUpdateService) autoUpdatePlugin(ctx context.Context, existing *Inst return fmt.Errorf("install updated plugin %s from %s to %s: %w", pluginID, oldVersion, newVersion, err) } - s.logger.Info("auto-updated plugin", + s.logger.InfoContext(ctx, "auto-updated plugin", "plugin_id", pluginID, "old_version", oldVersion, "new_version", newVersion, @@ -390,7 +390,7 @@ func (s *AutoUpdateService) notifyPluginUpdate(ctx context.Context, existing *In return fmt.Errorf("record available version for plugin %s: %w", existing.PluginID, err) } - s.logger.Info("update available for plugin", + s.logger.InfoContext(ctx, "update available for plugin", "plugin_id", existing.PluginID, "installed_version", existing.Version, "available_version", newVersion, diff --git a/internal/plugins/catalog_service.go b/internal/plugins/catalog_service.go index ae4797d4..688d0404 100644 --- a/internal/plugins/catalog_service.go +++ b/internal/plugins/catalog_service.go @@ -112,7 +112,7 @@ func (s *CatalogService) Fetch(ctx context.Context) ([]CatalogEntry, error) { index, err := s.fetchRepositoryIndex(ctx, repository.URL) if err != nil { - slog.Warn("skipping broken plugin repository", + slog.WarnContext(ctx, "skipping broken plugin repository", "component", "plugins", "repository_id", repository.ID, "repository_url", repository.URL, "error", err, @@ -128,7 +128,7 @@ func (s *CatalogService) Fetch(ctx context.Context) ([]CatalogEntry, error) { for _, pkg := range index.Plugins { entry, ok, err := s.catalogEntryFromPackage(repository, pkg) if err != nil { - slog.Warn("skipping invalid plugin catalog entry", + slog.WarnContext(ctx, "skipping invalid plugin catalog entry", "component", "plugins", "repository_id", repository.ID, "repository_url", repository.URL, "plugin_id", manifestPluginID(pkg.Manifest), diff --git a/internal/plugins/event_dispatcher.go b/internal/plugins/event_dispatcher.go index 09156da3..93147fe2 100644 --- a/internal/plugins/event_dispatcher.go +++ b/internal/plugins/event_dispatcher.go @@ -169,7 +169,7 @@ func decodeRawJSON(raw json.RawMessage) *structpb.Struct { func (d *EventDispatcher) fanOut(ctx context.Context, env events.Envelope, payload *structpb.Struct) { installations, err := d.installations.ListEnabled(ctx) if err != nil { - slog.Warn("plugin event dispatcher: list installations failed", "error", err) + slog.WarnContext(ctx, "plugin event dispatcher: list installations failed", "component", "plugins", "error", err) return } @@ -181,7 +181,7 @@ func (d *EventDispatcher) fanOut(ctx context.Context, env events.Envelope, paylo capabilities, err := d.installations.ListCapabilities(ctx, installation.ID) if err != nil { - slog.Warn("plugin event dispatcher: list capabilities failed", "installation_id", installation.ID, "error", err) + slog.WarnContext(ctx, "plugin event dispatcher: list capabilities failed", "component", "plugins", "installation_id", installation.ID, "error", err) continue } @@ -201,7 +201,7 @@ func (d *EventDispatcher) fanOut(ctx context.Context, env events.Envelope, paylo client, err := d.resolver.EventConsumerClient(ctx, installationID, capabilityID) if err != nil { - slog.Warn("plugin event dispatcher: resolve client failed", "installation_id", installationID, "capability_id", capabilityID, "error", err) + slog.WarnContext(ctx, "plugin event dispatcher: resolve client failed", "component", "plugins", "installation_id", installationID, "capability_id", capabilityID, "error", err) return } @@ -209,7 +209,7 @@ func (d *EventDispatcher) fanOut(ctx context.Context, env events.Envelope, paylo EventName: env.Event, Payload: payload, }); err != nil { - slog.Warn("plugin event dispatcher: delivery failed", "installation_id", installationID, "capability_id", capabilityID, "error", err) + slog.WarnContext(ctx, "plugin event dispatcher: delivery failed", "component", "plugins", "installation_id", installationID, "capability_id", capabilityID, "error", err) } }(installation.ID, capability.ID) // Deliver to the first matching event_consumer.v1 capability per diff --git a/internal/plugins/http_proxy.go b/internal/plugins/http_proxy.go index 1b0287fe..d56f6cfd 100644 --- a/internal/plugins/http_proxy.go +++ b/internal/plugins/http_proxy.go @@ -118,7 +118,7 @@ func (p *HTTPProxy) ServeRoute(w http.ResponseWriter, r *http.Request, installat client, err := p.service.HTTPRoutesClient(r.Context(), installationID, capabilityID) if err != nil { - slog.Error("plugin HTTPRoutesClient failed", "installation_id", installationID, "capability_id", capabilityID, "error", err) + slog.ErrorContext(r.Context(), "plugin HTTPRoutesClient failed", "component", "plugins", "installation_id", installationID, "capability_id", capabilityID, "error", err) http.Error(w, "plugin routes unavailable", http.StatusBadGateway) return } diff --git a/internal/plugins/service.go b/internal/plugins/service.go index 3fc61b26..78f75127 100644 --- a/internal/plugins/service.go +++ b/internal/plugins/service.go @@ -118,8 +118,8 @@ func (s *Service) OnLifecycleChange(ctx context.Context) { func(hook func(context.Context)) { defer func() { if recovered := recover(); recovered != nil { - slog.Error( - "plugin lifecycle hook panicked; continuing", + slog.ErrorContext(ctx, + "plugin lifecycle hook panicked; continuing", "component", "plugins", "panic", recovered, "stack", string(debug.Stack()), ) @@ -279,7 +279,7 @@ func (s *Service) UpdateToAvailableVersion(ctx context.Context, installationID i if err := s.installations.Update(ctx, installationID, UpdateInstallationInput{ AvailableVersion: &empty, }); err != nil { - slog.Warn("failed to clear available_version after update", + slog.WarnContext(ctx, "failed to clear available_version after update", "component", "plugins", "installation_id", installationID, "error", err) } @@ -408,8 +408,8 @@ func (s *Service) PreloadEnabled(ctx context.Context) error { } if _, err := s.ensureLoadedInstallation(ctx, installation); err != nil { if errors.Is(err, ErrArchiveNotFound) { - slog.Warn( - "plugin preload skipped: archive not found for enabled installation", + slog.WarnContext(ctx, + "plugin preload skipped: archive not found for enabled installation", "component", "plugins", "installation_id", installation.ID, "plugin_id", installation.PluginID, "version", installation.Version, @@ -681,7 +681,7 @@ func (s *Service) doEnsureClient(ctx context.Context, installationID int) (plugi if err == nil { installedManifest, manifestErr := LoadManifestFile(InstalledManifestPath(installation.InstallPath)) if manifestErr != nil { - slog.Warn("plugin installed manifest unavailable; reusing healthy client", + slog.WarnContext(ctx, "plugin installed manifest unavailable; reusing healthy client", "component", "plugins", "installation_id", installation.ID, "plugin_id", installation.PluginID, "version", installation.Version, @@ -693,7 +693,7 @@ func (s *Service) doEnsureClient(ctx context.Context, installationID int) (plugi if cachedManifest != nil && proto.Equal(cachedManifest, installedManifest) { return client, nil } - slog.Warn("plugin client manifest drift detected; restarting", + slog.WarnContext(ctx, "plugin client manifest drift detected; restarting", "component", "plugins", "installation_id", installation.ID, "plugin_id", installation.PluginID, "cached_plugin_id", manifestPluginID(cachedManifest), diff --git a/internal/plugins/service_connection.go b/internal/plugins/service_connection.go index c5170c18..8dbbaa8b 100644 --- a/internal/plugins/service_connection.go +++ b/internal/plugins/service_connection.go @@ -47,8 +47,8 @@ var runPluginConnectionCheck = func( } capability := metadataProviderConnectionCheckCapability(manifest, capabilityID) if !metadataProviderSupportsConnectionProbe(capability, "movie") { - slog.Debug( - "skipping metadata provider connection check for unsupported probe type", + slog.DebugContext(ctx, + "skipping metadata provider connection check for unsupported probe type", "component", "plugins", "plugin_id", manifest.GetPluginId(), "capability_id", capabilityID, "item_type", "movie", @@ -134,8 +134,8 @@ func (s *Service) TestGlobalConfig( defer func() { if stopErr := s.host.Stop(testInstallationID); stopErr != nil && !errors.Is(stopErr, pluginhost.ErrClientNotFound) { - slog.Warn( - "stopping temporary plugin connection check instance failed", + slog.WarnContext(ctx, + "stopping temporary plugin connection check instance failed", "component", "plugins", "installation_id", installationID, "test_installation_id", testInstallationID, "error", stopErr, diff --git a/internal/policy/decisionlog_cleanup.go b/internal/policy/decisionlog_cleanup.go index a72e56de..5d09e783 100644 --- a/internal/policy/decisionlog_cleanup.go +++ b/internal/policy/decisionlog_cleanup.go @@ -47,37 +47,37 @@ func CleanupDecisionLogsOnce( cutoff := time.Now().UTC().AddDate(0, 0, -days) if pm != nil { if err := pm.EnsureFuturePartitions(ctx); err != nil { - slog.Warn("policy decision log ensure future partitions error", "error", err) + slog.WarnContext(ctx, "policy decision log ensure future partitions error", "component", "policy", "error", err) firstErr = err } partitionCleanupFailed := false totalDeleted := int64(0) if dropped, err := pm.DropExpiredPartitions(ctx, cutoff); err != nil { - slog.Warn("policy decision log partition cleanup error", "error", err) + slog.WarnContext(ctx, "policy decision log partition cleanup error", "component", "policy", "error", err) partitionCleanupFailed = true if firstErr == nil { firstErr = err } } else if len(dropped) > 0 { - slog.Info("policy decision log dropped expired partitions", "partitions", dropped) + slog.InfoContext(ctx, "policy decision log dropped expired partitions", "component", "policy", "partitions", dropped) } if deleted, err := pm.DeleteExpiredRowsFromDefault(ctx, cutoff); err != nil { - slog.Warn("policy decision log default partition cleanup error", "error", err) + slog.WarnContext(ctx, "policy decision log default partition cleanup error", "component", "policy", "error", err) partitionCleanupFailed = true if firstErr == nil { firstErr = err } } else if deleted > 0 { totalDeleted += deleted - slog.Info("policy decision log default partition cleanup completed", "deleted", deleted, "retention_days", days) + slog.InfoContext(ctx, "policy decision log default partition cleanup completed", "component", "policy", "deleted", deleted, "retention_days", days) } if !partitionCleanupFailed { return totalDeleted, firstErr } - slog.Warn("policy decision log partition cleanup degraded, falling back to row deletes", "retention_days", days) + slog.WarnContext(ctx, "policy decision log partition cleanup degraded, falling back to row deletes", "component", "policy", "retention_days", days) } total, err := deleteExpiredDecisionRowsBefore(ctx, pool, cutoff) @@ -85,7 +85,7 @@ func CleanupDecisionLogsOnce( firstErr = err } if total > 0 { - slog.Info("policy decision log cleanup completed", "deleted", total, "retention_days", days) + slog.InfoContext(ctx, "policy decision log cleanup completed", "component", "policy", "deleted", total, "retention_days", days) } return total, firstErr } @@ -105,7 +105,7 @@ func deleteExpiredDecisionRowsBefore(ctx context.Context, pool *pgxpool.Pool, cu ) `, cutoff, decisionLogCleanupBatchSize) if err != nil { - slog.Warn("policy decision log cleanup error", "error", err) + slog.WarnContext(ctx, "policy decision log cleanup error", "component", "policy", "error", err) return total, err } deleted := result.RowsAffected() diff --git a/internal/policy/system_test.go b/internal/policy/system_test.go index 99db1465..b1818605 100644 --- a/internal/policy/system_test.go +++ b/internal/policy/system_test.go @@ -2,7 +2,6 @@ package policy import ( "context" - "io" "log/slog" "sync" "testing" @@ -141,7 +140,7 @@ func newStartedPolicySystem(t *testing.T, ctx context.Context, store *PolicyStor } func policyTestLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) + return slog.New(slog.DiscardHandler) } func activatePolicyVersion(t *testing.T, ctx context.Context, store *PolicyStore, documentID int64, sha string) (int64, int64) { diff --git a/internal/proxy/server.go b/internal/proxy/server.go index f53bab95..ec7f6524 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -240,7 +240,7 @@ func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) { if err != nil && r.Context().Err() == nil { // Headers already committed — log and let the client see a // truncated response. - slog.Error("stream subtitle (sup)", "error", err, "track", trackIndex, + slog.ErrorContext(r.Context(), "stream subtitle (sup)", "component", "proxy", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID) } return @@ -251,7 +251,7 @@ func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) { if requestedFormat == "ass" { data, err := playback.ExtractSubtitleWithFormat(r.Context(), claims.MediaPath, trackIndex, "ass", cfg.Playback.FFmpegPath) if err != nil { - slog.Error("extract subtitle (ass)", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID) + slog.ErrorContext(r.Context(), "extract subtitle (ass)", "component", "proxy", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID) http.Error(w, "subtitle extraction failed", http.StatusInternalServerError) return } @@ -261,14 +261,14 @@ func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) { data, format, err := playback.ExtractSubtitle(r.Context(), claims.MediaPath, trackIndex, cfg.Playback.FFmpegPath) if err != nil { - slog.Error("extract subtitle", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID) + slog.ErrorContext(r.Context(), "extract subtitle", "component", "proxy", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID) http.Error(w, "subtitle extraction failed", http.StatusInternalServerError) return } vtt, err := playback.ConvertToVTT(data, format) if err != nil { - slog.Error("convert to vtt", "error", err, "playback_session_id", claims.SessionID) + slog.ErrorContext(r.Context(), "convert to vtt", "component", "proxy", "error", err, "playback_session_id", claims.SessionID) http.Error(w, "subtitle conversion failed", http.StatusInternalServerError) return } @@ -291,7 +291,7 @@ func (s *Server) handleSubtitleFonts(w http.ResponseWriter, r *http.Request) { fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), claims.MediaPath, cfg.Playback.FFmpegPath) if err != nil { - slog.Error("extract subtitle fonts", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID) + slog.ErrorContext(r.Context(), "extract subtitle fonts", "component", "proxy", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID) http.Error(w, "subtitle font extraction failed", http.StatusInternalServerError) return } @@ -299,7 +299,7 @@ func (s *Server) handleSubtitleFonts(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") if err := json.NewEncoder(w).Encode(playback.EncodeSubtitleFontBundle(fonts)); err != nil { - slog.Warn("subtitle font response encode failed", "error", err, "playback_session_id", claims.SessionID) + slog.WarnContext(r.Context(), "subtitle font response encode failed", "component", "proxy", "error", err, "playback_session_id", claims.SessionID) } } @@ -333,7 +333,7 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl resp, err := s.httpClient.Do(req) if err != nil { - slog.Error("proxy to transcode node", "error", err, "url", targetURL, "playback_session_id", claims.SessionID) + slog.ErrorContext(r.Context(), "proxy to transcode node", "component", "proxy", "error", err, "url", targetURL, "playback_session_id", claims.SessionID) http.Error(w, "transcode node unavailable", http.StatusBadGateway) return } @@ -354,7 +354,7 @@ func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) { http.Error(w, "reload failed: "+err.Error(), http.StatusInternalServerError) return } - slog.Info("proxy force reload completed") + slog.InfoContext(r.Context(), "proxy force reload completed", slog.String("component", "proxy")) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/ratelimit/middleware.go b/internal/ratelimit/middleware.go index 63d844b4..e4cd2915 100644 --- a/internal/ratelimit/middleware.go +++ b/internal/ratelimit/middleware.go @@ -72,7 +72,7 @@ func (mw *Middleware) Reload(ctx context.Context) error { } } - slog.Info("rate limit config reloaded", "enabled", cfg.Enabled, "global_rps", cfg.GlobalReqPerSecond) + slog.InfoContext(ctx, "rate limit config reloaded", "component", "ratelimit", "enabled", cfg.Enabled, "global_rps", cfg.GlobalReqPerSecond) return nil } diff --git a/internal/ratelimit/redis.go b/internal/ratelimit/redis.go index 8c7c1fa0..0f4a434e 100644 --- a/internal/ratelimit/redis.go +++ b/internal/ratelimit/redis.go @@ -95,7 +95,7 @@ func (rl *RedisLimiter) Allow(ctx context.Context, key string, limit Rate) Allow if err != nil { // Fail-open: allow request if Redis is unreachable - slog.Warn("rate limit Redis error, allowing request", "error", err, "key", key) + slog.WarnContext(ctx, "rate limit Redis error, allowing request", "component", "ratelimit", "error", err, "key", key) return AllowResult{ Allowed: true, Limit: effectiveLimit, diff --git a/internal/recommendations/embeddings/client.go b/internal/recommendations/embeddings/client.go index 22c56882..54707b21 100644 --- a/internal/recommendations/embeddings/client.go +++ b/internal/recommendations/embeddings/client.go @@ -136,7 +136,7 @@ func (c *Client) embedOpenAI(ctx context.Context, texts []string) ([][]float32, return nil, fmt.Errorf("embedding API returned %d: %s", resp.StatusCode, string(respBody)) } wait := rateLimitBackoff(resp, attempt) - slog.Warn("rate limited by embedding API, waiting", "attempt", attempt+1, "wait", wait) + slog.WarnContext(ctx, "rate limited by embedding API, waiting", "component", "recommendations", "attempt", attempt+1, "wait", wait) select { case <-ctx.Done(): return nil, ctx.Err() @@ -219,7 +219,7 @@ func (c *Client) embedGemini(ctx context.Context, texts []string) ([][]float32, return nil, fmt.Errorf("gemini embedding API returned %d: %s", resp.StatusCode, string(respBody)) } wait := rateLimitBackoff(resp, attempt) - slog.Warn("rate limited by gemini embedding API, waiting", "attempt", attempt+1, "wait", wait) + slog.WarnContext(ctx, "rate limited by gemini embedding API, waiting", "component", "recommendations", "attempt", attempt+1, "wait", wait) select { case <-ctx.Done(): return nil, ctx.Err() diff --git a/internal/recommendations/reader.go b/internal/recommendations/reader.go index aab98a92..10fe256a 100644 --- a/internal/recommendations/reader.go +++ b/internal/recommendations/reader.go @@ -459,7 +459,7 @@ func (r *Reader) GetDiscoverRows(ctx context.Context, userID int, profileID stri excludeGenres := make(map[string]struct{}) clusters, clusterErr := r.repo.GetTasteClusters(ctx, userID, profileID) if clusterErr != nil { - slog.Warn("GetDiscoverRows: failed to load taste clusters for genre exclusion", "user_id", userID, "profile_id", profileID, "error", clusterErr) + slog.WarnContext(ctx, "GetDiscoverRows: failed to load taste clusters for genre exclusion", "component", "recommendations", "user_id", userID, "profile_id", profileID, "error", clusterErr) } for _, c := range clusters { for _, g := range c.DominantGenres { diff --git a/internal/recommendations/similar.go b/internal/recommendations/similar.go index 2c9294dc..f06181a9 100644 --- a/internal/recommendations/similar.go +++ b/internal/recommendations/similar.go @@ -50,7 +50,7 @@ func (e *Engine) SimilarItems(ctx context.Context, itemID string, limit int) ([] // 2. Fetch source metadata for validation pipeline. sourceMeta, err := e.repo.GetItemMetadata(ctx, itemID) if err != nil { - slog.Warn("could not fetch source metadata, skipping validation", "item_id", itemID, "error", err) + slog.WarnContext(ctx, "could not fetch source metadata, skipping validation", "component", "recommendations", "item_id", itemID, "error", err) } sourceType := "" if sourceMeta != nil { @@ -422,7 +422,7 @@ func (e *Engine) embedBatch(ctx context.Context, items []*models.MediaItem, text // Batch failed — fall back to embedding one at a time so a single // oversized item doesn't block the entire job. - slog.Warn("batch embed failed, falling back to single-item mode", "error", err, "batch_size", len(chunkItems)) + slog.WarnContext(ctx, "batch embed failed, falling back to single-item mode", "component", "recommendations", "error", err, "batch_size", len(chunkItems)) for i, item := range chunkItems { if ctx.Err() != nil { return total, ctx.Err() @@ -436,7 +436,7 @@ func (e *Engine) embedBatch(ctx context.Context, items []*models.MediaItem, text if ctx.Err() != nil { return total, ctx.Err() } - slog.Warn("skipping item, embed failed", "item_id", item.ContentID, "error", embedErr) + slog.WarnContext(ctx, "skipping item, embed failed", "component", "recommendations", "item_id", item.ContentID, "error", embedErr) continue } if len(vecs) > 0 { @@ -444,7 +444,7 @@ func (e *Engine) embedBatch(ctx context.Context, items []*models.MediaItem, text return total, fmt.Errorf("embed item %s: %w", item.ContentID, err) } if storeErr := e.repo.UpsertEmbedding(ctx, item.ContentID, vecs[0], model, single); storeErr != nil { - slog.Warn("skipping item, store failed", "item_id", item.ContentID, "error", storeErr) + slog.WarnContext(ctx, "skipping item, store failed", "component", "recommendations", "item_id", item.ContentID, "error", storeErr) continue } total++ @@ -461,7 +461,7 @@ func (e *Engine) embedBatch(ctx context.Context, items []*models.MediaItem, text return total, fmt.Errorf("embed item %s: %w", item.ContentID, err) } if err := e.repo.UpsertEmbedding(ctx, item.ContentID, vectors[i], model, chunkTexts[i]); err != nil { - slog.Warn("skipping item, store failed", "item_id", item.ContentID, "error", err) + slog.WarnContext(ctx, "skipping item, store failed", "component", "recommendations", "item_id", item.ContentID, "error", err) continue } total++ @@ -480,7 +480,7 @@ func (e *Engine) hydrateItemPeople(ctx context.Context, items []*models.MediaIte peopleMap, err := e.personRepo.ListForItems(ctx, ids) if err != nil { - slog.Warn("failed to hydrate item people for embeddings", "error", err) + slog.WarnContext(ctx, "failed to hydrate item people for embeddings", "component", "recommendations", "error", err) return } diff --git a/internal/recommendations/taste.go b/internal/recommendations/taste.go index cc4f216e..371fc107 100644 --- a/internal/recommendations/taste.go +++ b/internal/recommendations/taste.go @@ -311,7 +311,7 @@ func (e *Engine) RefreshTasteProfile(ctx context.Context, userID int, profileID items, err := e.itemRepo.GetByIDs(ctx, allIDs) if err != nil { - slog.Error("taste: failed to fetch items for genre enrichment, cluster labels will be degraded", + slog.ErrorContext(ctx, "taste: failed to fetch items for genre enrichment, cluster labels will be degraded", "component", "recommendations", "user_id", userID, "profile_id", profileID, "id_count", len(allIDs), "error", err) } else { diff --git a/internal/recommendations/worker.go b/internal/recommendations/worker.go index 5bdc3960..f95e133e 100644 --- a/internal/recommendations/worker.go +++ b/internal/recommendations/worker.go @@ -206,7 +206,7 @@ func (w *Worker) RequestProfileRefresh(ctx context.Context, userID int, profileI w.clearProfileRefreshPending(key) case <-time.After(10 * time.Millisecond): w.clearProfileRefreshPending(key) - slog.Warn("profile refresh queue full; dropping request", "user_id", userID, "profile_id", profileID) + slog.WarnContext(ctx, "profile refresh queue full; dropping request", "component", "recommendations", "user_id", userID, "profile_id", profileID) } } @@ -385,21 +385,21 @@ func (w *Worker) cacheGlobalRows(ctx context.Context, repo *Repo, expires string popular, _ := repo.GetPopularItems(ctx, 30, CacheCandidateLimit) if len(popular) > 0 { if err := repo.UpsertRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "", popular, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err) } } recentlyAdded, _ := repo.GetRecentlyAddedItems(ctx, 14, CacheCandidateLimit) if len(recentlyAdded) > 0 { if err := repo.UpsertRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeRecentlyAdded, "", recentlyAdded, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err) } } topRated, _ := repo.GetTopRatedItems(ctx, 5, CacheCandidateLimit) if len(topRated) > 0 { if err := repo.UpsertRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeTopRated, "", topRated, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err) } } @@ -408,7 +408,7 @@ func (w *Worker) cacheGlobalRows(ctx context.Context, repo *Repo, expires string items, _ := repo.GetGenreSamplerItems(ctx, genre, CacheCandidateLimit) if len(items) > 0 { if err := repo.UpsertRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeGenreSamplerPrefix+genre, "", items, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err) } } } @@ -419,7 +419,7 @@ func (w *Worker) cacheUserRows(ctx context.Context, repo *Repo, userID int, prof var cached int watchedSet, err := w.engine.watchedItemIDSet(ctx, userID, profileID) if err != nil { - slog.Warn("failed to load watched items for recommendation cache", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "failed to load watched items for recommendation cache", "component", "recommendations", "user_id", userID, "profile_id", profileID, "error", err) watchedSet = nil } watchedIDs := scoredItemIDsFromSet(watchedSet) @@ -427,7 +427,7 @@ func (w *Worker) cacheUserRows(ctx context.Context, repo *Repo, userID int, prof if aggregatedRow, err := w.engine.buildAggregatedRow(ctx, userID, profileID, CacheCandidateLimit, watchedIDs, accessFilter); err == nil && aggregatedRow != nil && len(aggregatedRow.Items) > 0 { if err := repo.UpsertRecommendationCache(ctx, userID, profileID, RecTypeForYouMain, "", aggregatedRow.Items, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err) } else { cached++ } @@ -436,7 +436,7 @@ func (w *Worker) cacheUserRows(ctx context.Context, repo *Repo, userID int, prof // Cache per-cluster ForYou rows. clusterRows, err := w.engine.buildClusterRows(ctx, userID, profileID, CacheCandidateLimit, watchedIDs, accessFilter) if err != nil { - slog.Warn("failed to build cluster recommendations for cache", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "failed to build cluster recommendations for cache", "component", "recommendations", "user_id", userID, "profile_id", profileID, "error", err) } for _, row := range clusterRows { if len(row.Items) == 0 { @@ -445,7 +445,7 @@ func (w *Worker) cacheUserRows(ctx context.Context, repo *Repo, userID int, prof recType := fmt.Sprintf("%s%d", RecTypeForYouClusterPrefix, row.ClusterIndex) if err := repo.UpsertRecommendationCache(ctx, userID, profileID, recType, "", row.Items, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err) } cached++ } @@ -454,7 +454,7 @@ func (w *Worker) cacheUserRows(ctx context.Context, repo *Repo, userID int, prof items, err := w.engine.SimilarUsersLiked(ctx, userID, profileID, CacheCandidateLimit) if err == nil && len(items) > 0 { if err := repo.UpsertRecommendationCache(ctx, userID, profileID, RecTypeSimilarUsersLiked, "", items, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err) } else { cached++ } @@ -462,7 +462,7 @@ func (w *Worker) cacheUserRows(ctx context.Context, repo *Repo, userID int, prof recentCompleted, err := w.engine.signalReader().RecentCompletedItemIDs(ctx, userID, profileID, 3) if err != nil { - slog.Warn("failed to load recent completed items for recommendation cache", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "failed to load recent completed items for recommendation cache", "component", "recommendations", "user_id", userID, "profile_id", profileID, "error", err) return cached } for _, sourceItemID := range recentCompleted { @@ -471,7 +471,7 @@ func (w *Worker) cacheUserRows(ctx context.Context, repo *Repo, userID int, prof continue } if err := repo.UpsertRecommendationCache(ctx, userID, profileID, RecTypeBecauseWatched, sourceItemID, items, expires); err != nil { - slog.Warn("failed to cache recommendations", "error", err, "rec_type", RecTypeBecauseWatched, "source_item_id", sourceItemID) + slog.WarnContext(ctx, "failed to cache recommendations", "component", "recommendations", "error", err, "rec_type", RecTypeBecauseWatched, "source_item_id", sourceItemID) continue } cached++ @@ -500,14 +500,14 @@ func (w *Worker) refreshStaleProfiles(ctx context.Context) { repo := NewRepo(w.engine.pool) stale, err := repo.GetStaleProfiles(ctx, 50) if err != nil { - slog.Error("staleness check failed", "error", err) + slog.ErrorContext(ctx, "staleness check failed", "component", "recommendations", "error", err) return } if len(stale) == 0 { return } - slog.Info("refreshing stale taste profiles", "count", len(stale)) + slog.InfoContext(ctx, "refreshing stale taste profiles", "component", "recommendations", "count", len(stale)) for _, p := range stale { w.RequestProfileRefresh(ctx, p.UserID, p.ProfileID) } @@ -525,7 +525,7 @@ func (w *Worker) profileRefreshLoop(ctx context.Context) { return case req := <-w.profileRefreshCh: if err := w.refreshProfile(ctx, req.userID, req.profileID); err != nil { - slog.Error("profile recommendation refresh failed", "user_id", req.userID, "profile_id", req.profileID, "error", err) + slog.ErrorContext(ctx, "profile recommendation refresh failed", "component", "recommendations", "user_id", req.userID, "profile_id", req.profileID, "error", err) } w.clearProfileRefreshPending(profileRefreshKey(req.userID, req.profileID)) } @@ -544,7 +544,7 @@ func (w *Worker) refreshProfile(ctx context.Context, userID int, profileID strin expires := time.Now().Add(24 * time.Hour).Format(time.RFC3339) w.cacheUserRows(refreshCtx, repo, userID, profileID, expires) if err := repo.ClearStaleAt(refreshCtx, userID, profileID); err != nil { - slog.Warn("failed to clear stale profile marker", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "failed to clear stale profile marker", "component", "recommendations", "user_id", userID, "profile_id", profileID, "error", err) } return nil } diff --git a/internal/requests/notify.go b/internal/requests/notify.go index cb7d12a9..c88ddd1b 100644 --- a/internal/requests/notify.go +++ b/internal/requests/notify.go @@ -65,7 +65,7 @@ func (s *Service) notifyFulfilledPending(ctx context.Context) { } candidates, err := s.store.ListFulfilledUnnotified(ctx, notifyFulfilledLimit) if err != nil { - slog.WarnContext(ctx, "request fulfill-notify: list candidates failed", "err", err) + slog.WarnContext(ctx, "request fulfill-notify: list candidates failed", "component", "requests", "err", err) return } for _, req := range candidates { @@ -74,7 +74,7 @@ func (s *Service) notifyFulfilledPending(ctx context.Context) { } matches, err := s.lookupPresence(ctx, req.MediaType, []PresenceCandidate{requestPresenceCandidate(*req)}) if err != nil { - slog.WarnContext(ctx, "request fulfill-notify: presence lookup failed", + slog.WarnContext(ctx, "request fulfill-notify: presence lookup failed", "component", "requests", "request_id", req.ID, "tmdb_id", req.TMDBID, "err", err) continue } @@ -83,12 +83,12 @@ func (s *Service) notifyFulfilledPending(ctx context.Context) { continue // not in the catalog yet; retry next run } if err := s.notifier.NotifyFulfilled(ctx, *req, match.ContentID); err != nil { - slog.WarnContext(ctx, "request fulfill-notify: dispatch failed", + slog.WarnContext(ctx, "request fulfill-notify: dispatch failed", "component", "requests", "request_id", req.ID, "err", err) continue } if err := s.store.MarkFulfilledNotified(ctx, req.ID); err != nil { - slog.WarnContext(ctx, "request fulfill-notify: mark failed", + slog.WarnContext(ctx, "request fulfill-notify: mark failed", "component", "requests", "request_id", req.ID, "err", err) } } diff --git a/internal/requests/presence.go b/internal/requests/presence.go index ad1224e0..362d34ee 100644 --- a/internal/requests/presence.go +++ b/internal/requests/presence.go @@ -90,7 +90,7 @@ func (p *CatalogPresence) Lookup(ctx context.Context, mediaType MediaType, candi } if row.MatchedProvider != "tmdb" && p.tmdbBackfill != nil { if err := p.tmdbBackfill.AttachTMDBID(ctx, row.MediaID, string(mediaType), id); err != nil { - slog.Warn("requests: failed to backfill tmdb id from presence lookup", + slog.WarnContext(ctx, "requests: failed to backfill tmdb id from presence lookup", "component", "requests", "content_id", row.MediaID, "media_type", mediaType, "tmdb_id", id, diff --git a/internal/requests/service.go b/internal/requests/service.go index 357803ee..826a6d07 100644 --- a/internal/requests/service.go +++ b/internal/requests/service.go @@ -89,7 +89,7 @@ func (s *Service) populateRequesterIdentity(ctx context.Context, req *Request) { } email, username, err := s.requesterIdentity.ResolveRequester(ctx, req.RequestedByUserID) if err != nil { - slog.WarnContext(ctx, "requests: requester identity resolve failed; attributing to admin", "user_id", req.RequestedByUserID, "error", err) + slog.WarnContext(ctx, "requests: requester identity resolve failed; attributing to admin", "component", "requests", "user_id", req.RequestedByUserID, "error", err) return } req.RequesterEmail, req.RequesterUsername = email, username @@ -171,7 +171,7 @@ func (s *Service) resolveRouterConnections(ctx context.Context, fc *fulfillConte // in.APIKeyRef was decrypted by the repo on read; empty means unconfigured. apiKey := strings.TrimSpace(in.APIKeyRef) if apiKey == "" { - slog.WarnContext(ctx, "requests: skipping router connection with no api key", "connection_id", in.ID) + slog.WarnContext(ctx, "requests: skipping router connection with no api key", "component", "requests", "connection_id", in.ID) continue } // Lock on the first SUCCESSFULLY resolved connection so a skipped @@ -714,7 +714,7 @@ func (s *Service) ReconcileRequests(ctx context.Context, limit int) (ReconcileRe } change, err := s.reconcileRequest(ctx, *req, fc) if err != nil { - slog.WarnContext(ctx, "request reconcile failed", + slog.WarnContext(ctx, "request reconcile failed", "component", "requests", "request_id", req.ID, "media_type", req.MediaType, "tmdb_id", req.TMDBID, @@ -1406,7 +1406,7 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor returned := map[Quality]bool{} for _, rt := range targets { if !validQuality[rt.Quality] { - slog.WarnContext(ctx, "requests: plugin returned unknown quality; skipping", "request_id", req.ID, "quality", string(rt.Quality)) + slog.WarnContext(ctx, "requests: plugin returned unknown quality; skipping", "component", "requests", "request_id", req.ID, "quality", string(rt.Quality)) continue } if returned[rt.Quality] || healthy[rt.Quality] { @@ -1414,7 +1414,7 @@ func (s *Service) submitApprovedRequest(ctx context.Context, req Request, actor } if rt.ConnectionID != "" { if _, ok := connKind[rt.ConnectionID]; !ok { - slog.WarnContext(ctx, "requests: plugin returned unknown connection id; skipping target", "request_id", req.ID, "connection_id", rt.ConnectionID) + slog.WarnContext(ctx, "requests: plugin returned unknown connection id; skipping target", "component", "requests", "request_id", req.ID, "connection_id", rt.ConnectionID) continue } } diff --git a/internal/s3client/client.go b/internal/s3client/client.go index a1d87e4f..bda67437 100644 --- a/internal/s3client/client.go +++ b/internal/s3client/client.go @@ -430,7 +430,7 @@ func (c *Client) DeleteObjects(ctx context.Context, bucket string, keys []string // Fall back to individual deletes if batch is not supported. for _, key := range batch { if delErr := c.DeleteObject(ctx, bucket, key); delErr != nil { - slog.Warn("s3 DeleteObjects fallback: failed to delete", "key", key, "error", delErr) + slog.WarnContext(ctx, "s3 DeleteObjects fallback: failed to delete", "component", "s3client", "key", key, "error", delErr) continue } deleted++ @@ -442,7 +442,7 @@ func (c *Client) DeleteObjects(ctx context.Context, bucket string, keys []string if out != nil { deleted -= len(out.Errors) for _, e := range out.Errors { - slog.Warn("s3 DeleteObjects: partial failure", + slog.WarnContext(ctx, "s3 DeleteObjects: partial failure", "component", "s3client", "key", aws.ToString(e.Key), "code", aws.ToString(e.Code), "message", aws.ToString(e.Message)) } } diff --git a/internal/scanner/audiobook_cover.go b/internal/scanner/audiobook_cover.go index 6a1440e6..323b4f8a 100644 --- a/internal/scanner/audiobook_cover.go +++ b/internal/scanner/audiobook_cover.go @@ -76,7 +76,7 @@ func ExtractAndUploadAudiobookCover( cmd.Stdout = stdout cmd.Stderr = stderr if err := cmd.Run(); err != nil { - slog.Debug("audiobook cover: ffmpeg failed (likely no embedded artwork)", + slog.DebugContext(ctx, "audiobook cover: ffmpeg failed (likely no embedded artwork)", "component", "scanner", "path", audioFilePath, "error", err, "stderr", stderr.String()) return "", "" } @@ -86,7 +86,7 @@ func ExtractAndUploadAudiobookCover( } basePath, ext, thumbhash, err := cacher.CacheAudiobookCover(ctx, data, contentID) if err != nil { - slog.Warn("audiobook cover: imagecache upload failed", + slog.WarnContext(ctx, "audiobook cover: imagecache upload failed", "component", "scanner", "path", audioFilePath, "error", err) return "", "" } diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index ed654328..ce2b490c 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -228,7 +228,7 @@ func collectAudiobookRootScans(ctx context.Context, folderID int, roots []string } if walkErr != nil { scan.walkFailures++ - slog.Warn("audiobook scan: walk error", "path", path, "error", walkErr) + slog.WarnContext(ctx, "audiobook scan: walk error", "component", "scanner", "path", path, "error", walkErr) return nil } if !d.IsDir() { @@ -237,7 +237,7 @@ func collectAudiobookRootScans(ctx context.Context, folderID int, roots []string entries, err := os.ReadDir(path) if err != nil { scan.walkFailures++ - slog.Warn("audiobook scan: read dir failed", "path", path, "error", err) + slog.WarnContext(ctx, "audiobook scan: read dir failed", "component", "scanner", "path", path, "error", err) return nil } hadAudio := false @@ -261,7 +261,7 @@ func collectAudiobookRootScans(ctx context.Context, folderID int, roots []string } } if scan.failed() { - slog.Warn("audiobook scan: root walk incomplete; root excluded from missing-file reconciliation", + slog.WarnContext(ctx, "audiobook scan: root walk incomplete; root excluded from missing-file reconciliation", "component", "scanner", "folder_id", folderID, "root", cleanRoot, "walk_failures", scan.walkFailures, @@ -327,10 +327,10 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF // empty-walk guard requires operator confirmation before deleting. if len(reconcileRoots) > 0 { if err := s.reconcileAudiobookMissingFiles(ctx, folder, reconcileRoots, seenPaths, sawFiles, fullScan); err != nil { - slog.Warn("audiobook scan: missing-file reconcile failed", "folder_id", folder.ID, "error", err) + slog.WarnContext(ctx, "audiobook scan: missing-file reconcile failed", "component", "scanner", "folder_id", folder.ID, "error", err) } } else if len(scans) > 0 { - slog.Warn("audiobook scan: every root walk failed; skipping missing-file reconciliation", + slog.WarnContext(ctx, "audiobook scan: every root walk failed; skipping missing-file reconciliation", "component", "scanner", "folder_id", folder.ID) } return nil @@ -340,7 +340,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF // (hundreds of ms each), so single-threaded scans of a large library // take days. Worker pool brings this to ~hours. workers := audiobookScanWorkers() - slog.Info("audiobook scan: starting", + slog.InfoContext(ctx, "audiobook scan: starting", "component", "scanner", "folder_id", folder.ID, "candidates", len(candidates), "workers", workers, @@ -378,7 +378,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF failMu.Lock() failures = append(failures, fmt.Errorf("%s: %w", path, err)) failMu.Unlock() - slog.Warn("audiobook scan: folder failed", + slog.WarnContext(ctx, "audiobook scan: folder failed", "component", "scanner", "folder_id", folder.ID, "path", path, "error", err, @@ -386,7 +386,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF } n := atomic.AddInt64(&processed, 1) if n%500 == 0 { - slog.Info("audiobook scan: progress", + slog.InfoContext(ctx, "audiobook scan: progress", "component", "scanner", "folder_id", folder.ID, "processed", n, "failed", atomic.LoadInt64(&failed), @@ -414,7 +414,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF return cancelErr } - slog.Info("audiobook scan: completed", + slog.InfoContext(ctx, "audiobook scan: completed", "component", "scanner", "folder_id", folder.ID, "processed", atomic.LoadInt64(&processed), "failed", atomic.LoadInt64(&failed), @@ -432,7 +432,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF // Reconcile files that vanished from disk now that the full walk's // seenPaths is known and the scan completed without cancellation. if err := s.reconcileAudiobookMissingFiles(ctx, folder, reconcileRoots, seenPaths, sawFiles, fullScan); err != nil { - slog.Warn("audiobook scan: missing-file reconcile failed", "folder_id", folder.ID, "error", err) + slog.WarnContext(ctx, "audiobook scan: missing-file reconcile failed", "component", "scanner", "folder_id", folder.ID, "error", err) } return nil } @@ -471,7 +471,7 @@ func (s *Scanner) reconcileAudiobookMissingFiles(ctx context.Context, folder *mo return err } if !allowed { - slog.Warn("audiobook scan: walk saw zero files but the database has files under the scanned roots; skipping reconciliation until cleanup is confirmed", + slog.WarnContext(ctx, "audiobook scan: walk saw zero files but the database has files under the scanned roots; skipping reconciliation until cleanup is confirmed", "component", "scanner", "folder_id", folder.ID, "existing_files", existingCount) return nil } @@ -491,7 +491,7 @@ func (s *Scanner) reconcileAudiobookMissingFiles(ctx context.Context, folder *mo } if mf.MissingSince == nil { if err := s.fileRepo.MarkMissing(ctx, mf.ID, now); err != nil { - slog.Error("audiobook scan: failed to mark file missing", + slog.ErrorContext(ctx, "audiobook scan: failed to mark file missing", "component", "scanner", "folder_id", folder.ID, "path", mf.FilePath, "error", err) continue } @@ -506,7 +506,7 @@ func (s *Scanner) reconcileAudiobookMissingFiles(ctx context.Context, folder *mo return fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err) } if trashed > 0 { - slog.Info("audiobook scan: emptied trash", "folder_id", folder.ID, "deleted", trashed) + slog.InfoContext(ctx, "audiobook scan: emptied trash", "component", "scanner", "folder_id", folder.ID, "deleted", trashed) } } @@ -521,7 +521,7 @@ func (s *Scanner) reconcileAudiobookMissingFiles(ctx context.Context, folder *mo } } if missing > 0 || removedMemberships > 0 || deletedItems > 0 { - slog.Info("audiobook scan: reconciled missing files", + slog.InfoContext(ctx, "audiobook scan: reconciled missing files", "component", "scanner", "folder_id", folder.ID, "missing", missing, "memberships_removed", removedMemberships, "items_deleted", deletedItems) } @@ -531,7 +531,7 @@ func (s *Scanner) reconcileAudiobookMissingFiles(ctx context.Context, folder *mo func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.MediaFolder, folderPath string, skipped *int64) error { existingContentID, isUnchanged, skipErr := s.audiobookFolderShouldSkip(ctx, folder, folderPath) if skipErr != nil { - slog.Warn("audiobook scan: skip-check failed, falling through", + slog.WarnContext(ctx, "audiobook scan: skip-check failed, falling through", "component", "scanner", "folder_id", folder.ID, "path", folderPath, "error", skipErr, @@ -557,7 +557,7 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M return fmt.Errorf("upsert audiobook files: %w", err) } if err := applyAudiobookSidecarCover(ctx, s.itemRepo, s.imageCacher, contentID, folderPath); err != nil { - slog.Warn("audiobook scan: sidecar cover upload failed", + slog.WarnContext(ctx, "audiobook scan: sidecar cover upload failed", "component", "scanner", "folder_id", folder.ID, "content_id", contentID, "path", folderPath, @@ -566,7 +566,7 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M } if len(parsed.Files) > 0 && s.fileRepo != nil { if _, err := applyAudiobookEmbeddedCover(ctx, s.itemRepo, s.fileRepo.Pool(), FFmpegPathFromFFprobe(s.ffprobePath), s.imageCacher, parsed.Files[0].Path, contentID); err != nil { - slog.Warn("audiobook scan: embedded cover failed", + slog.WarnContext(ctx, "audiobook scan: embedded cover failed", "component", "scanner", "folder_id", folder.ID, "content_id", contentID, "path", parsed.Files[0].Path, @@ -601,7 +601,7 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M } } s.autoLinkLiteraryWork(ctx, contentID) - slog.Info("audiobook scan: indexed", + slog.InfoContext(ctx, "audiobook scan: indexed", "component", "scanner", "folder_id", folder.ID, "content_id", contentID, "title", parsed.Title, diff --git a/internal/scanner/ebook_scan.go b/internal/scanner/ebook_scan.go index 713f71e9..2f10c42d 100644 --- a/internal/scanner/ebook_scan.go +++ b/internal/scanner/ebook_scan.go @@ -95,7 +95,7 @@ func collectEbookRootScans(ctx context.Context, folderID int, roots []string) ([ } } if scan.failed() { - slog.Warn("ebook scan: root walk incomplete; root excluded from missing-file reconciliation", + slog.WarnContext(ctx, "ebook scan: root walk incomplete; root excluded from missing-file reconciliation", "component", "scanner", "folder_id", folderID, "root", cleanRoot, "walk_failures", scan.walkFailures, @@ -146,7 +146,7 @@ func (s *Scanner) scanEbookPaths(ctx context.Context, folder *models.MediaFolder } workers := ebookScanWorkers() - slog.Info("ebook scan: starting", + slog.InfoContext(ctx, "ebook scan: starting", "component", "scanner", "folder_id", folder.ID, "candidates", len(candidates), "workers", workers, @@ -186,7 +186,7 @@ func (s *Scanner) scanEbookPaths(ctx context.Context, folder *models.MediaFolder failMu.Lock() failures = append(failures, fmt.Errorf("%s: %w", path, err)) failMu.Unlock() - slog.Warn("ebook scan: file failed", + slog.WarnContext(ctx, "ebook scan: file failed", "component", "scanner", "folder_id", folder.ID, "path", path, "error", err, @@ -196,7 +196,7 @@ func (s *Scanner) scanEbookPaths(ctx context.Context, folder *models.MediaFolder if n%500 == 0 || n == int64(len(candidates)) { failedCount := atomic.LoadInt64(&failed) skippedCount := atomic.LoadInt64(&skipped) - slog.Info("ebook scan: progress", + slog.InfoContext(ctx, "ebook scan: progress", "component", "scanner", "folder_id", folder.ID, "processed", n, "failed", failedCount, @@ -228,7 +228,7 @@ func (s *Scanner) scanEbookPaths(ctx context.Context, folder *models.MediaFolder return cancelErr } - slog.Info("ebook scan: completed", + slog.InfoContext(ctx, "ebook scan: completed", "component", "scanner", "folder_id", folder.ID, "processed", atomic.LoadInt64(&processed), "failed", atomic.LoadInt64(&failed), @@ -288,7 +288,7 @@ func (s *Scanner) reconcileEbookScan(ctx context.Context, folder *models.MediaFo reconcileRoots, sawFiles := splitEbookReconcileRoots(scans) if len(reconcileRoots) == 0 { if len(scans) > 0 { - slog.Warn("ebook scan: every root walk failed; skipping missing-file reconciliation", + slog.WarnContext(ctx, "ebook scan: every root walk failed; skipping missing-file reconciliation", "component", "scanner", "folder_id", folder.ID, ) } @@ -317,7 +317,7 @@ func (s *Scanner) reconcileEbookScan(ctx context.Context, folder *models.MediaFo return err } if !allowed { - slog.Warn("ebook scan: walk saw zero ebooks but the database has files under the scanned roots; skipping reconciliation until cleanup is confirmed", + slog.WarnContext(ctx, "ebook scan: walk saw zero ebooks but the database has files under the scanned roots; skipping reconciliation until cleanup is confirmed", "component", "scanner", "folder_id", folder.ID, "existing_files", existingCount, "full_scan", fullScan, @@ -363,7 +363,7 @@ func (s *Scanner) reconcileMissingEbookFiles(ctx context.Context, folder *models } if mf.MissingSince == nil { if err := s.fileRepo.MarkMissing(ctx, mf.ID, now); err != nil { - slog.Error("ebook scan: failed to mark file missing", + slog.ErrorContext(ctx, "ebook scan: failed to mark file missing", "component", "scanner", "folder_id", folder.ID, "path", mf.FilePath, "error", err, @@ -381,7 +381,7 @@ func (s *Scanner) reconcileMissingEbookFiles(ctx context.Context, folder *models return fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err) } if trashed > 0 { - slog.Info("ebook scan: emptied trash", "folder_id", folder.ID, "deleted", trashed) + slog.InfoContext(ctx, "ebook scan: emptied trash", "component", "scanner", "folder_id", folder.ID, "deleted", trashed) } } @@ -399,7 +399,7 @@ func (s *Scanner) reconcileMissingEbookFiles(ctx context.Context, folder *models } if missing > 0 || removedMemberships > 0 || deletedItems > 0 { - slog.Info("ebook scan: reconciled missing files", + slog.InfoContext(ctx, "ebook scan: reconciled missing files", "component", "scanner", "folder_id", folder.ID, "missing", missing, "memberships_removed", removedMemberships, @@ -435,7 +435,7 @@ func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFo existingContentID, isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt) if skipErr != nil { - slog.Warn("ebook scan: skip-check failed, falling through", + slog.WarnContext(ctx, "ebook scan: skip-check failed, falling through", "component", "scanner", "folder_id", folder.ID, "path", filePath, "error", skipErr, @@ -481,7 +481,7 @@ func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFo return fmt.Errorf("upsert ebook file: %w", err) } if err := applyEbookLocalCover(ctx, s.itemRepo, s.imageCacher, contentID, filePath, &parsed); err != nil { - slog.Warn("ebook scan: local cover upload failed", + slog.WarnContext(ctx, "ebook scan: local cover upload failed", "component", "scanner", "folder_id", folder.ID, "content_id", contentID, "path", filePath, @@ -503,7 +503,7 @@ func (s *Scanner) reconcileEbookFile(ctx context.Context, folder *models.MediaFo } } s.autoLinkLiteraryWork(ctx, contentID) - slog.Info("ebook scan: indexed", + slog.InfoContext(ctx, "ebook scan: indexed", "component", "scanner", "folder_id", folder.ID, "content_id", contentID, "title", parsed.Title, @@ -519,11 +519,11 @@ func (s *Scanner) autoLinkLiteraryWork(ctx context.Context, contentID string) { } workID, linked, err := s.literaryWorkLinker.AutoLinkContent(ctx, contentID) if err != nil { - slog.Warn("literary work auto-link failed", "content_id", contentID, "error", err) + slog.WarnContext(ctx, "literary work auto-link failed", "component", "scanner", "content_id", contentID, "error", err) return } if linked { - slog.Info("literary work auto-linked", "content_id", contentID, "work_id", workID) + slog.InfoContext(ctx, "literary work auto-linked", "component", "scanner", "content_id", contentID, "work_id", workID) } } diff --git a/internal/scanner/extras.go b/internal/scanner/extras.go index a89b66f7..121b8f0e 100644 --- a/internal/scanner/extras.go +++ b/internal/scanner/extras.go @@ -252,7 +252,7 @@ func (s *Scanner) processExtraFiles( info, err := os.Stat(candidate.Path) if err != nil { - slog.Warn("scanner: extra stat failed", "path", candidate.Path, "error", err) + slog.WarnContext(ctx, "scanner: extra stat failed", "component", "scanner", "path", candidate.Path, "error", err) stats.Errors++ continue } @@ -260,12 +260,12 @@ func (s *Scanner) processExtraFiles( extraID := contentid.ForLocal(candidate.Path) parentID, err := s.resolveExtraParent(ctx, folder.ID, candidate, rootSet) if err != nil { - slog.Warn("scanner: extra parent lookup failed", "path", candidate.Path, "error", err) + slog.WarnContext(ctx, "scanner: extra parent lookup failed", "component", "scanner", "path", candidate.Path, "error", err) stats.Errors++ continue } if parentID == "" { - slog.Debug("scanner: extra parent unresolved, deferring", + slog.DebugContext(ctx, "scanner: extra parent unresolved, deferring", "component", "scanner", "path", candidate.Path, "kind", candidate.Kind) stats.Skipped++ continue @@ -280,7 +280,7 @@ func (s *Scanner) processExtraFiles( Kind: candidate.Kind, Title: naming.ExtraTitleFromFile(candidate.Path), }); err != nil { - slog.Warn("scanner: extra upsert failed", "path", candidate.Path, "error", err) + slog.WarnContext(ctx, "scanner: extra upsert failed", "component", "scanner", "path", candidate.Path, "error", err) stats.Errors++ continue } @@ -320,7 +320,7 @@ func (s *Scanner) processExtraFiles( // is set, so a pre-existing primary row (e.g. a "-trailer" file // previously scanned as a movie version) converts in one statement. if _, err := s.fileRepo.Upsert(ctx, mf); err != nil { - slog.Warn("scanner: extra file upsert failed", "path", candidate.Path, "error", err) + slog.WarnContext(ctx, "scanner: extra file upsert failed", "component", "scanner", "path", candidate.Path, "error", err) stats.Errors++ continue } @@ -333,7 +333,7 @@ func (s *Scanner) processExtraFiles( } if stats.New+stats.Updated+stats.Skipped+stats.Errors > 0 { - slog.Info("scanner: processed extras", + slog.InfoContext(ctx, "scanner: processed extras", "component", "scanner", "folder_id", folder.ID, "new", stats.New, "updated", stats.Updated, diff --git a/internal/scanner/manga_scan.go b/internal/scanner/manga_scan.go index 24b94bbb..b29c56d6 100644 --- a/internal/scanner/manga_scan.go +++ b/internal/scanner/manga_scan.go @@ -53,7 +53,7 @@ func (s *Scanner) scanMangaPaths(ctx context.Context, folder *models.MediaFolder } workers := ebookScanWorkers() - slog.Info("manga scan: starting", + slog.InfoContext(ctx, "manga scan: starting", "component", "scanner", "folder_id", folder.ID, "candidates", len(candidates), "workers", workers, @@ -93,7 +93,7 @@ func (s *Scanner) scanMangaPaths(ctx context.Context, folder *models.MediaFolder failMu.Lock() failures = append(failures, fmt.Errorf("%s: %w", path, err)) failMu.Unlock() - slog.Warn("manga scan: file failed", + slog.WarnContext(ctx, "manga scan: file failed", "component", "scanner", "folder_id", folder.ID, "path", path, "error", err, @@ -103,7 +103,7 @@ func (s *Scanner) scanMangaPaths(ctx context.Context, folder *models.MediaFolder if n%500 == 0 || n == int64(len(candidates)) { failedCount := atomic.LoadInt64(&failed) skippedCount := atomic.LoadInt64(&skipped) - slog.Info("manga scan: progress", + slog.InfoContext(ctx, "manga scan: progress", "component", "scanner", "folder_id", folder.ID, "processed", n, "failed", failedCount, @@ -135,7 +135,7 @@ func (s *Scanner) scanMangaPaths(ctx context.Context, folder *models.MediaFolder return cancelErr } - slog.Info("manga scan: completed", + slog.InfoContext(ctx, "manga scan: completed", "component", "scanner", "folder_id", folder.ID, "processed", atomic.LoadInt64(&processed), "failed", atomic.LoadInt64(&failed), @@ -215,7 +215,7 @@ func (s *Scanner) deleteOrphanedMangaSeries(ctx context.Context, folderID int) e return fmt.Errorf("commit orphaned manga series delete tx: %w", err) } if len(deletedIDs) > 0 { - slog.Info("manga scan: removed orphaned series", "folder_id", folderID, "deleted", len(deletedIDs)) + slog.InfoContext(ctx, "manga scan: removed orphaned series", "component", "scanner", "folder_id", folderID, "deleted", len(deletedIDs)) } return nil } @@ -237,7 +237,7 @@ func (s *Scanner) reconcileMangaFile(ctx context.Context, folder *models.MediaFo _, isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt) if skipErr != nil { - slog.Warn("manga scan: skip-check failed, falling through", + slog.WarnContext(ctx, "manga scan: skip-check failed, falling through", "component", "scanner", "folder_id", folder.ID, "path", filePath, "error", skipErr, @@ -277,7 +277,7 @@ func (s *Scanner) reconcileMangaFile(ctx context.Context, folder *models.MediaFo return "", fmt.Errorf("upsert manga chapter file: %w", err) } if err := applyEbookLocalCover(ctx, s.itemRepo, s.imageCacher, contentID, filePath, &parsed); err != nil { - slog.Warn("manga scan: local cover upload failed", + slog.WarnContext(ctx, "manga scan: local cover upload failed", "component", "scanner", "folder_id", folder.ID, "content_id", contentID, "path", filePath, @@ -317,7 +317,7 @@ func (s *Scanner) reconcileMangaFile(ctx context.Context, folder *models.MediaFo } } - slog.Debug("manga scan: indexed", + slog.DebugContext(ctx, "manga scan: indexed", "component", "scanner", "folder_id", folder.ID, "chapter_id", chapterID, "series_id", seriesID, @@ -403,7 +403,7 @@ func (s *Scanner) findOrCreateMangaSeries(ctx context.Context, folderID int, ser _, delErr := tx.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, id) if delErr != nil { _ = tx.Rollback(ctx) - slog.Warn("manga scan: failed to delete duplicate series item", + slog.WarnContext(ctx, "manga scan: failed to delete duplicate series item", "component", "scanner", "folder_id", folderID, "content_id", id, "error", delErr, diff --git a/internal/scanner/podcast_scan.go b/internal/scanner/podcast_scan.go index f5be171e..fc332bbf 100644 --- a/internal/scanner/podcast_scan.go +++ b/internal/scanner/podcast_scan.go @@ -41,7 +41,7 @@ func (s *Scanner) ScanPodcastFolder(ctx context.Context, folder *models.MediaFol } entries, err := os.ReadDir(root) if err != nil { - slog.Warn("podcast scan: read root failed", "root", root, "error", err) + slog.WarnContext(ctx, "podcast scan: read root failed", "component", "scanner", "root", root, "error", err) attempted++ failures = append(failures, fmt.Errorf("read root %s: %w", root, err)) continue @@ -66,7 +66,7 @@ func (s *Scanner) ScanPodcastFolder(ctx context.Context, folder *models.MediaFol if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return err } - slog.Warn("podcast scan: show failed", + slog.WarnContext(ctx, "podcast scan: show failed", "component", "scanner", "folder_id", folder.ID, "path", subPath, "error", err, @@ -85,7 +85,7 @@ func (s *Scanner) ScanPodcastFolder(ctx context.Context, folder *models.MediaFol return fmt.Errorf("podcast scan failed for every attempted folder_id=%d: %w", folder.ID, errors.Join(failures...)) } if err := s.reconcilePodcastMissingFiles(ctx, folder, reconcileRoots, seenPaths, len(seenPaths) > 0); err != nil { - slog.Warn("podcast scan: missing-file reconcile failed", "folder_id", folder.ID, "error", err) + slog.WarnContext(ctx, "podcast scan: missing-file reconcile failed", "component", "scanner", "folder_id", folder.ID, "error", err) } return nil } @@ -114,7 +114,7 @@ func (s *Scanner) reconcilePodcastShow(ctx context.Context, folder *models.Media return nil, fmt.Errorf("upsert podcast library membership: %w", err) } - slog.Info("podcast scan: indexed", + slog.InfoContext(ctx, "podcast scan: indexed", "component", "scanner", "folder_id", folder.ID, "content_id", showContentID, "title", parsed.Title, @@ -241,7 +241,7 @@ func (s *Scanner) reconcilePodcastMissingFiles(ctx context.Context, folder *mode return err } if !allowed { - slog.Warn("podcast scan: walk saw zero files but the database has files under the scanned roots; skipping reconciliation until cleanup is confirmed", + slog.WarnContext(ctx, "podcast scan: walk saw zero files but the database has files under the scanned roots; skipping reconciliation until cleanup is confirmed", "component", "scanner", "folder_id", folder.ID, "existing_files", existingCount) return nil } @@ -261,7 +261,7 @@ func (s *Scanner) reconcilePodcastMissingFiles(ctx context.Context, folder *mode } if mf.MissingSince == nil { if err := s.fileRepo.MarkMissing(ctx, mf.ID, now); err != nil { - slog.Error("podcast scan: failed to mark file missing", + slog.ErrorContext(ctx, "podcast scan: failed to mark file missing", "component", "scanner", "folder_id", folder.ID, "path", mf.FilePath, "error", err) continue } @@ -276,7 +276,7 @@ func (s *Scanner) reconcilePodcastMissingFiles(ctx context.Context, folder *mode return fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err) } if trashed > 0 { - slog.Info("podcast scan: emptied trash", "folder_id", folder.ID, "deleted", trashed) + slog.InfoContext(ctx, "podcast scan: emptied trash", "component", "scanner", "folder_id", folder.ID, "deleted", trashed) } } @@ -291,7 +291,7 @@ func (s *Scanner) reconcilePodcastMissingFiles(ctx context.Context, folder *mode } } if missing > 0 || removedMemberships > 0 || deletedItems > 0 { - slog.Info("podcast scan: reconciled missing files", + slog.InfoContext(ctx, "podcast scan: reconciled missing files", "component", "scanner", "folder_id", folder.ID, "missing", missing, "memberships_removed", removedMemberships, "items_deleted", deletedItems) } diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 04de6ba6..6f89e033 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -478,7 +478,7 @@ func walkLogicalTree( info, err := os.Lstat(physicalPath) if err != nil { - slog.Warn("scanner: walk lstat failed", "path", logicalPath, "physical_path", physicalPath, "error", err) + slog.WarnContext(ctx, "scanner: walk lstat failed", "component", "scanner", "path", logicalPath, "physical_path", physicalPath, "error", err) recordWalkFailure(walkFailures) return nil } @@ -486,13 +486,13 @@ func walkLogicalTree( if info.Mode()&os.ModeSymlink != 0 { resolved, err := filepath.EvalSymlinks(physicalPath) if err != nil { - slog.Warn("scanner: symlink resolve failed", "path", logicalPath, "physical_path", physicalPath, "error", err) + slog.WarnContext(ctx, "scanner: symlink resolve failed", "component", "scanner", "path", logicalPath, "physical_path", physicalPath, "error", err) recordWalkFailure(walkFailures) return nil } targetInfo, err := os.Stat(resolved) if err != nil { - slog.Warn("scanner: symlink stat failed", "path", logicalPath, "resolved_path", resolved, "error", err) + slog.WarnContext(ctx, "scanner: symlink stat failed", "component", "scanner", "path", logicalPath, "resolved_path", resolved, "error", err) recordWalkFailure(walkFailures) return nil } @@ -520,7 +520,7 @@ func walkLogicalTree( canonicalDir, err := canonicalWalkPath(physicalPath) if err != nil { - slog.Warn("scanner: canonical path resolution failed", "path", logicalPath, "physical_path", physicalPath, "error", err) + slog.WarnContext(ctx, "scanner: canonical path resolution failed", "component", "scanner", "path", logicalPath, "physical_path", physicalPath, "error", err) recordWalkFailure(walkFailures) return nil } @@ -538,7 +538,7 @@ func walkLogicalTree( entries, err := os.ReadDir(physicalPath) if err != nil { - slog.Warn("scanner: directory read failed", "path", logicalPath, "physical_path", physicalPath, "error", err) + slog.WarnContext(ctx, "scanner: directory read failed", "component", "scanner", "path", logicalPath, "physical_path", physicalPath, "error", err) recordWalkFailure(walkFailures) return nil } @@ -555,13 +555,13 @@ func walkLogicalTree( if entry.Type()&os.ModeSymlink != 0 { resolved, err := filepath.EvalSymlinks(physicalChild) if err != nil { - slog.Warn("scanner: symlink resolve failed", "path", logicalChild, "physical_path", physicalChild, "error", err) + slog.WarnContext(ctx, "scanner: symlink resolve failed", "component", "scanner", "path", logicalChild, "physical_path", physicalChild, "error", err) recordWalkFailure(walkFailures) continue } targetInfo, err := os.Stat(resolved) if err != nil { - slog.Warn("scanner: symlink stat failed", "path", logicalChild, "resolved_path", resolved, "error", err) + slog.WarnContext(ctx, "scanner: symlink stat failed", "component", "scanner", "path", logicalChild, "resolved_path", resolved, "error", err) recordWalkFailure(walkFailures) continue } @@ -674,7 +674,7 @@ func (s *Scanner) scanPaths( if walkErr != nil { return nil, fmt.Errorf("walking media roots: %w", walkErr) } - slog.Info("scanner: discovered files", + slog.InfoContext(ctx, "scanner: discovered files", "component", "scanner", "folder_id", folder.ID, "scope", firstScope(reconcileRoots), "files", len(filePaths), @@ -758,17 +758,17 @@ func (s *Scanner) scanPaths( } action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path], subtitleCache) if processErr != nil { - slog.Error("scanner: file processing failed", "path", path, "error", processErr) + slog.ErrorContext(ctx, "scanner: file processing failed", "component", "scanner", "path", path, "error", processErr) errorCount.Add(1) continue } switch action { case actionNew: newCount.Add(1) - slog.Debug("scanner: new file added", "path", path) + slog.DebugContext(ctx, "scanner: new file added", "component", "scanner", "path", path) case actionUpdated: updatedCount.Add(1) - slog.Debug("scanner: file updated", "path", path, "reasons", updateReasons) + slog.DebugContext(ctx, "scanner: file updated", "component", "scanner", "path", path, "reasons", updateReasons) case actionUnchanged: unchangedCount.Add(1) } @@ -792,7 +792,7 @@ func (s *Scanner) scanPaths( case <-ticker.C: processed := int(processedCount.Load()) total := len(filePaths) - slog.Info("scanner: processing progress", + slog.InfoContext(ctx, "scanner: processing progress", "component", "scanner", "folder_id", folder.ID, "scope", firstScope(reconcileRoots), "processed", processed, @@ -875,7 +875,7 @@ func (s *Scanner) scanPaths( // Only mark as missing if not already marked. if existing.MissingSince == nil { if err := s.fileRepo.MarkMissing(ctx, existing.ID, now); err != nil { - slog.Error("scanner: failed to mark file missing", + slog.ErrorContext(ctx, "scanner: failed to mark file missing", "component", "scanner", "path", existing.FilePath, "error", err, ) @@ -896,7 +896,7 @@ func (s *Scanner) scanPaths( return nil, fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err) } if trashed > 0 { - slog.Info("scanner: emptied trash", "folder_id", folder.ID, "deleted", trashed) + slog.InfoContext(ctx, "scanner: emptied trash", "component", "scanner", "folder_id", folder.ID, "deleted", trashed) } result.FilesDeleted += trashed } @@ -926,7 +926,7 @@ func (s *Scanner) scanPaths( if err := s.seriesQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil { return nil, fmt.Errorf("syncing series match queue for folder %d: %w", folder.ID, err) } - slog.Info("metadata: series root queue sync", + slog.InfoContext(ctx, "metadata: series root queue sync", "component", "scanner", "folder_id", folder.ID, "scope", "folder", ) @@ -935,7 +935,7 @@ func (s *Scanner) scanPaths( if err := s.seriesQueueSyncer.SyncInScope(ctx, folder.ID, scopePath); err != nil { return nil, fmt.Errorf("syncing series match queue for scope %q: %w", scopePath, err) } - slog.Info("metadata: series root queue sync", + slog.InfoContext(ctx, "metadata: series root queue sync", "component", "scanner", "folder_id", folder.ID, "scope", scopePath, ) @@ -947,7 +947,7 @@ func (s *Scanner) scanPaths( if err := s.movieQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil { return nil, fmt.Errorf("syncing movie match queue for folder %d: %w", folder.ID, err) } - slog.Info("metadata: movie file queue sync", + slog.InfoContext(ctx, "metadata: movie file queue sync", "component", "scanner", "folder_id", folder.ID, "scope", "folder", ) @@ -956,7 +956,7 @@ func (s *Scanner) scanPaths( if err := s.movieQueueSyncer.SyncInScope(ctx, folder.ID, scopePath); err != nil { return nil, fmt.Errorf("syncing movie match queue for scope %q: %w", scopePath, err) } - slog.Info("metadata: movie file queue sync", + slog.InfoContext(ctx, "metadata: movie file queue sync", "component", "scanner", "folder_id", folder.ID, "scope", scopePath, ) @@ -1103,7 +1103,7 @@ func (s *Scanner) scanFolderByRoots( return nil, fmt.Errorf("emptying trash for folder %d: %w", folder.ID, err) } if trashed > 0 { - slog.Info("scanner: emptied trash", "folder_id", folder.ID, "deleted", trashed) + slog.InfoContext(ctx, "scanner: emptied trash", "component", "scanner", "folder_id", folder.ID, "deleted", trashed) } result.FilesDeleted += trashed } @@ -1134,7 +1134,7 @@ func (s *Scanner) scanFolderByRoots( if err := s.seriesQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil { return nil, fmt.Errorf("syncing series match queue for folder %d: %w", folder.ID, err) } - slog.Info("metadata: series root queue sync", + slog.InfoContext(ctx, "metadata: series root queue sync", "component", "scanner", "folder_id", folder.ID, "scope", "folder", ) @@ -1148,7 +1148,7 @@ func (s *Scanner) scanFolderByRoots( if err := s.movieQueueSyncer.SyncForFolder(ctx, folder.ID); err != nil { return nil, fmt.Errorf("syncing movie match queue for folder %d: %w", folder.ID, err) } - slog.Info("metadata: movie file queue sync", + slog.InfoContext(ctx, "metadata: movie file queue sync", "component", "scanner", "folder_id", folder.ID, "scope", "folder", ) @@ -1195,7 +1195,7 @@ func (s *Scanner) scanScope( if walkErr != nil { return nil, fmt.Errorf("walking media roots for %q: %w", reconcileRoots[0], walkErr) } - slog.Info("scanner: discovered files", + slog.InfoContext(ctx, "scanner: discovered files", "component", "scanner", "folder_id", folder.ID, "scope", firstScope(reconcileRoots), "files", len(filePaths), @@ -1249,17 +1249,17 @@ func (s *Scanner) scanScope( } action, updateReasons, processErr := s.processFile(ctx, path, folder, existingByPath, existingContentStatuses, rootInference.Assignments[path], groupInference.Assignments[path], subtitleCache) if processErr != nil { - slog.Error("scanner: file processing failed", "path", path, "error", processErr) + slog.ErrorContext(ctx, "scanner: file processing failed", "component", "scanner", "path", path, "error", processErr) errorCount.Add(1) continue } switch action { case actionNew: newCount.Add(1) - slog.Debug("scanner: new file added", "path", path) + slog.DebugContext(ctx, "scanner: new file added", "component", "scanner", "path", path) case actionUpdated: updatedCount.Add(1) - slog.Debug("scanner: file updated", "path", path, "reasons", updateReasons) + slog.DebugContext(ctx, "scanner: file updated", "component", "scanner", "path", path, "reasons", updateReasons) case actionUnchanged: unchangedCount.Add(1) } @@ -1283,7 +1283,7 @@ func (s *Scanner) scanScope( case <-ticker.C: processed := int(processedCount.Load()) total := len(filePaths) - slog.Info("scanner: processing progress", + slog.InfoContext(ctx, "scanner: processing progress", "component", "scanner", "folder_id", folder.ID, "scope", firstScope(reconcileRoots), "processed", processed, @@ -1383,7 +1383,7 @@ func (s *Scanner) applyScopedScan( } if existing.MissingSince == nil { if err := s.fileRepo.MarkMissing(ctx, existing.ID, now); err != nil { - slog.Error("scanner: failed to mark file missing", + slog.ErrorContext(ctx, "scanner: failed to mark file missing", "component", "scanner", "path", existing.FilePath, "error", err, ) @@ -1738,7 +1738,7 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. if err := s.seriesQueueSyncer.SyncInScope(ctx, folder.ID, scopePath); err != nil { return fmt.Errorf("syncing series match queue for file: %w", err) } - slog.Info("metadata: series root queue sync", + slog.InfoContext(ctx, "metadata: series root queue sync", "component", "scanner", "folder_id", folder.ID, "scope", scopePath, ) @@ -1747,7 +1747,7 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. if err := s.movieQueueSyncer.SyncInScope(ctx, folder.ID, filepath.Clean(filePath)); err != nil { return fmt.Errorf("syncing movie match queue for file: %w", err) } - slog.Info("metadata: movie file queue sync", + slog.InfoContext(ctx, "metadata: movie file queue sync", "component", "scanner", "folder_id", folder.ID, "scope", filepath.Clean(filePath), ) @@ -1764,7 +1764,7 @@ func (s *Scanner) watchFolderContext(ctx context.Context, folderID int) (context watchCtx, cancel := context.WithCancel(ctx) enabled, err := s.folderEnabledState(watchCtx, folderID) if err != nil { - slog.Warn("scanner: failed to load folder state", "folder_id", folderID, "error", err) + slog.WarnContext(ctx, "scanner: failed to load folder state", "component", "scanner", "folder_id", folderID, "error", err) cancel() return watchCtx, cancel } @@ -1784,7 +1784,7 @@ func (s *Scanner) watchFolderContext(ctx context.Context, folderID int) (context case <-ticker.C: enabled, err := s.folderEnabledState(watchCtx, folderID) if err != nil { - slog.Warn("scanner: failed to refresh folder state", "folder_id", folderID, "error", err) + slog.WarnContext(ctx, "scanner: failed to refresh folder state", "component", "scanner", "folder_id", folderID, "error", err) continue } if !enabled { @@ -1878,7 +1878,7 @@ func (s *Scanner) processFile( var subErr error externalSubs, subErr = subtitleCache.Detect(filePath) if subErr != nil { - slog.Warn("scanner: subtitle detection failed", "path", filePath, "error", subErr) + slog.WarnContext(ctx, "scanner: subtitle detection failed", "component", "scanner", "path", filePath, "error", subErr) externalSubs = nil externalSubsChecked = false return externalSubs @@ -2002,7 +2002,7 @@ func (s *Scanner) processFile( return 0, nil, fmt.Errorf("upserting markers for file %s: %w", filePath, markerErr) } if !applied { - slog.Debug("scanner: skipped lower-priority s3 markers", "path", filePath, "hash", fileHash) + slog.DebugContext(ctx, "scanner: skipped lower-priority s3 markers", "component", "scanner", "path", filePath, "hash", fileHash) } } @@ -2086,7 +2086,7 @@ func (s *Scanner) processFile( return 0, nil, fmt.Errorf("upserting markers for file %s: %w", filePath, markerErr) } if !applied { - slog.Debug("scanner: skipped lower-priority s3 markers", "path", filePath, "hash", fileHash) + slog.DebugContext(ctx, "scanner: skipped lower-priority s3 markers", "component", "scanner", "path", filePath, "hash", fileHash) } } @@ -2436,7 +2436,7 @@ func (s *Scanner) enqueueMetadataWork(ctx context.Context, folder *models.MediaF if err := s.metadataQueue.EnqueueMovieFile(ctx, file.ID); err != nil { return err } - slog.Debug("metadata queue: movie file enqueued", + slog.DebugContext(ctx, "metadata queue: movie file enqueued", "component", "scanner", "folder_id", folder.ID, "file_id", file.ID, "path", file.FilePath, @@ -2448,7 +2448,7 @@ func (s *Scanner) enqueueMetadataWork(ctx context.Context, folder *models.MediaF if err := s.metadataQueue.EnqueueSeriesRoot(ctx, folder.ID, file.ObservedRootPath); err != nil { return err } - slog.Debug("metadata queue: series root touched", + slog.DebugContext(ctx, "metadata queue: series root touched", "component", "scanner", "folder_id", folder.ID, "observed_root_path", file.ObservedRootPath, "file_id", file.ID, @@ -2940,7 +2940,7 @@ func (s *Scanner) probeFile(ctx context.Context, filePath string) (*ProbeData, s if s.ffprobePath != "" { probe, err := ProbeFile(ctx, s.ffprobePath, filePath) if err != nil { - slog.Warn("scanner: ffprobe failed", "path", filePath, "error", err) + slog.WarnContext(ctx, "scanner: ffprobe failed", "component", "scanner", "path", filePath, "error", err) return nil, "local" } return probe, "local" @@ -2964,7 +2964,7 @@ func (s *Scanner) fetchMarkers(ctx context.Context, fileHash string) *IntroCredi var markers IntroCreditsMarkers if err := json.Unmarshal(data, &markers); err != nil { - slog.Warn("scanner: markers JSON parse failed", + slog.WarnContext(ctx, "scanner: markers JSON parse failed", "component", "scanner", "hash", fileHash, "error", err, ) diff --git a/internal/scanqueue/service.go b/internal/scanqueue/service.go index c0d81674..c0ec05cb 100644 --- a/internal/scanqueue/service.go +++ b/internal/scanqueue/service.go @@ -475,7 +475,7 @@ func (s *Service) heartbeatLoop(ctx context.Context, runID string, stop <-chan s err := s.repo.TouchHeartbeat(touchCtx, runID) cancel() if err != nil && !errors.Is(err, ErrScanRunNotFound) { - slog.Warn("scan queue: failed to touch heartbeat", "scan_id", runID, "error", err) + slog.WarnContext(ctx, "scan queue: failed to touch heartbeat", "component", "scanqueue", "scan_id", runID, "error", err) } } } diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index 8ceb5944..55464524 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -466,7 +466,7 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res if ContinueTypeAllowsNextUp(continueType) && nextUpMode == "combined" { nextUpItems, nextUpMeta, nextUpErr := f.FetchNextUpItems(ctx, userID, profileID, effectiveLibID, effectiveLibraryIDs, filter, limit) if nextUpErr != nil { - slog.Error("fetching next-up items", "error", nextUpErr) + slog.ErrorContext(ctx, "fetching next-up items", "component", "sections", "error", nextUpErr) } else { orderedItems = append(orderedItems, nextUpItems...) maps.Copy(itemMeta, nextUpMeta) @@ -971,7 +971,7 @@ func episodeOrdinal(meta SectionItemMeta) int { func (f *Fetcher) listContinueWatchingDismissals(ctx context.Context, store userstore.UserStore, profileID string) catalog.HomeDismissalIndex { dismissals, err := store.ListHomeDismissals(ctx, profileID, userstore.HomeSurfaceContinueWatching) if err != nil { - slog.Error("listing continue watching dismissals", "profile_id", profileID, "error", err) + slog.ErrorContext(ctx, "listing continue watching dismissals", "component", "sections", "profile_id", profileID, "error", err) return catalog.HomeDismissalIndex{} } return catalog.NewHomeDismissalIndex(dismissals) @@ -984,13 +984,13 @@ func (f *Fetcher) filterNextUpDismissals(ctx context.Context, userID int, profil store, err := f.StoreProvider.ForUser(ctx, userID) if err != nil { - slog.Error("getting user store for next-up dismissals", "profile_id", profileID, "error", err) + slog.ErrorContext(ctx, "getting user store for next-up dismissals", "component", "sections", "profile_id", profileID, "error", err) return results } dismissals, err := store.ListHomeDismissals(ctx, profileID, userstore.HomeSurfaceNextUp) if err != nil { - slog.Error("listing next-up dismissals", "profile_id", profileID, "error", err) + slog.ErrorContext(ctx, "listing next-up dismissals", "component", "sections", "profile_id", profileID, "error", err) return results } if len(dismissals) == 0 { @@ -1030,7 +1030,7 @@ func (f *Fetcher) FetchAll(ctx context.Context, resolved []ResolvedSection, libr if libraryIDs != nil { attrs = append(attrs, "library_ids", append([]int(nil), libraryIDs...)) } - slog.Warn("slow aggregate section fetch", attrs...) + slog.WarnContext(ctx, "slow aggregate section fetch", append([]any{"component", "sections"}, attrs...)...) } return results } @@ -1056,7 +1056,7 @@ func fetchAllWithRunner(ctx context.Context, resolved []ResolvedSection, maxConc result, err := runner(ctx, sec) if err != nil { - slog.Error("fetching section items", "section_id", sec.ID, "type", sec.SectionType, "error", err) + slog.ErrorContext(ctx, "fetching section items", "component", "sections", "section_id", sec.ID, "type", sec.SectionType, "error", err) result = SectionWithItems{ ResolvedSection: sec, Items: []*models.MediaItem{}, @@ -4029,7 +4029,7 @@ func (f *Fetcher) applyMangaChapterSeriesMeta(ctx context.Context, items []*mode } seriesMeta, err := f.FetchMangaChapterSeriesMeta(ctx, ids) if err != nil { - slog.Warn("continue-reading: manga series linkage lookup failed", "error", err) + slog.WarnContext(ctx, "continue-reading: manga series linkage lookup failed", "component", "sections", "error", err) return } for chapterID, sm := range seriesMeta { diff --git a/internal/sections/trending_refresher.go b/internal/sections/trending_refresher.go index 88a00892..968a2f28 100644 --- a/internal/sections/trending_refresher.go +++ b/internal/sections/trending_refresher.go @@ -155,7 +155,7 @@ func (r *TrendingRefresher) refreshCombo(ctx context.Context, source, window str entries, err := r.fetchEntries(ctx, source, window, trendingFetchCap) if err != nil { - r.log().Error("trending refresh: fetch failed", "source", source, "window", window, "error", err) + r.log().ErrorContext(ctx, "trending refresh: fetch failed", "source", source, "window", window, "error", err) _ = r.Snapshots.RecordAttempt(ctx, source, window, "error", err.Error(), now) return "error" } @@ -167,7 +167,7 @@ func (r *TrendingRefresher) refreshCombo(ctx context.Context, source, window str contentIDs, err := r.resolveIDs(ctx, entries) if err != nil { - r.log().Error("trending refresh: resolve failed", "source", source, "window", window, "error", err) + r.log().ErrorContext(ctx, "trending refresh: resolve failed", "source", source, "window", window, "error", err) _ = r.Snapshots.RecordAttempt(ctx, source, window, "error", err.Error(), now) return "error" } @@ -177,7 +177,7 @@ func (r *TrendingRefresher) refreshCombo(ctx context.Context, source, window str status = "empty" } if err := r.Snapshots.SaveSuccess(ctx, source, window, contentIDs, len(entries), status, now); err != nil { - r.log().Error("trending refresh: save failed", "source", source, "window", window, "error", err) + r.log().ErrorContext(ctx, "trending refresh: save failed", "source", source, "window", window, "error", err) return "error" } return status diff --git a/internal/subtitles/ai/service.go b/internal/subtitles/ai/service.go index 112e8dae..a8654323 100644 --- a/internal/subtitles/ai/service.go +++ b/internal/subtitles/ai/service.go @@ -278,7 +278,7 @@ func (s *Service) run(ctx context.Context, job *Job) { return } if err := s.repo.UpdateProgress(ctx, job.ID, JobStatusRunning, 0, "Loading subtitle"); err != nil { - s.logger.Warn("failed to mark subtitle ai job running", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to mark subtitle ai job running", "job", job.ID, "error", err) } cues, sourceLang, err := s.loadSource(ctx, job) @@ -338,7 +338,7 @@ func (s *Service) run(ctx context.Context, job *Job) { // language can use it as a plain text source without re-running ASR. func (s *Service) runTranscribe(ctx context.Context, job *Job) { if err := s.repo.UpdateProgress(ctx, job.ID, JobStatusRunning, 0, "Preparing audio"); err != nil { - s.logger.Warn("failed to mark subtitle ai job running", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to mark subtitle ai job running", "job", job.ID, "error", err) } file, err := s.files.GetByID(ctx, job.MediaFileID) @@ -462,7 +462,7 @@ func (s *Service) runTranscribe(ctx context.Context, job *Job) { if job.Kind == JobKindTranscribe { if err := s.repo.CompleteJob(storeCtx, job.ID, transcript.ID); err != nil { - s.logger.Warn("failed to mark subtitle ai job complete", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to mark subtitle ai job complete", "job", job.ID, "error", err) } if streaming { s.notifier.TranslationCompleted(storeCtx, job.SessionID, job.MediaFileID, job.ID, trackKey, @@ -571,7 +571,7 @@ func (s *Service) finishTranslatedTrack( } if err := s.repo.CompleteJob(storeCtx, job.ID, sub.ID); err != nil { - s.logger.Warn("failed to mark subtitle ai job complete", "job", job.ID, "error", err) + s.logger.WarnContext(ctx, "failed to mark subtitle ai job complete", "job", job.ID, "error", err) } if s.notifier != nil { @@ -617,14 +617,14 @@ func (s *Service) finishWithErrorNotify(ctx context.Context, job *Job, err error msg = "cancelled" } if dbErr := s.repo.FailJob(context.WithoutCancel(ctx), job.ID, status, msg); dbErr != nil { - s.logger.Warn("failed to record subtitle ai job failure", "job", job.ID, "error", dbErr) + s.logger.WarnContext(ctx, "failed to record subtitle ai job failure", "job", job.ID, "error", dbErr) } if notify && job.SessionID != "" && s.notifier != nil { s.notifier.TranslationFailed(context.WithoutCancel(ctx), job.SessionID, job.MediaFileID, job.ID, liveTrackKey(job.ID), msg) } if status == JobStatusFailed { - s.logger.Warn("subtitle ai job failed", "job", job.ID, "media_file", job.MediaFileID, "error", err) + s.logger.WarnContext(ctx, "subtitle ai job failed", "job", job.ID, "media_file", job.MediaFileID, "error", err) } } diff --git a/internal/taskmanager/manager.go b/internal/taskmanager/manager.go index 4fbf1872..1faf6f3f 100644 --- a/internal/taskmanager/manager.go +++ b/internal/taskmanager/manager.go @@ -66,13 +66,13 @@ func (m *TaskManager) Start(ctx context.Context) { configs, err := m.triggerRepo.GetTriggers(ctx, key) if err != nil { - m.logger.Error("failed to load triggers", "task", key, "error", err) + m.logger.ErrorContext(ctx, "failed to load triggers", "task", key, "error", err) } if len(configs) == 0 { configs = w.task.DefaultTriggers() if len(configs) > 0 { if err := m.triggerRepo.SetTriggers(ctx, key, configs); err != nil { - m.logger.Error("failed to persist default triggers", "task", key, "error", err) + m.logger.ErrorContext(ctx, "failed to persist default triggers", "task", key, "error", err) } } } @@ -82,7 +82,7 @@ func (m *TaskManager) Start(ctx context.Context) { go m.triggerLoop(ctx, w) } - m.logger.Info("task manager started", "tasks", len(m.tasks)) + m.logger.InfoContext(ctx, "task manager started", "tasks", len(m.tasks)) } // triggerLoop listens on all trigger channels for a worker and runs the task @@ -148,7 +148,7 @@ func (m *TaskManager) triggerLoop(ctx context.Context, w *taskWorker) { shouldRun, shouldRunErr := m.shouldRunScheduledTask(ctx, w) if shouldRunErr != nil { - m.logger.Warn("scheduled task preflight failed; running task", + m.logger.WarnContext(ctx, "scheduled task preflight failed; running task", "task", w.task.Key(), "error", shouldRunErr) } if shouldRunErr == nil && !shouldRun { @@ -169,7 +169,7 @@ func (m *TaskManager) triggerLoop(ctx context.Context, w *taskWorker) { if result != nil { if insertErr := m.historyRepo.Insert(ctx, *result); insertErr != nil { - m.logger.Error("failed to persist execution result", + m.logger.ErrorContext(ctx, "failed to persist execution result", "task", w.task.Key(), "error", insertErr) } } @@ -241,7 +241,7 @@ func (m *TaskManager) RunTask(ctx context.Context, key string) error { if result != nil { if insertErr := m.historyRepo.Insert(ctx, *result); insertErr != nil { - m.logger.Error("failed to persist execution result", "task", key, "error", insertErr) + m.logger.ErrorContext(ctx, "failed to persist execution result", "task", key, "error", insertErr) } } diff --git a/internal/taskmanager/manager_test.go b/internal/taskmanager/manager_test.go index 84401172..f37bac50 100644 --- a/internal/taskmanager/manager_test.go +++ b/internal/taskmanager/manager_test.go @@ -2,7 +2,6 @@ package taskmanager_test import ( "context" - "io" "log/slog" "reflect" "sync" @@ -209,7 +208,7 @@ func TestTaskManagerStartSeedsCleanupTaskDefaults(t *testing.T) { triggerRepo, fakeExecutionRepository{}, newFakeTrigger, - slog.New(slog.NewTextHandler(io.Discard, nil)), + slog.New(slog.DiscardHandler), ) manager.Register(taskdefs.NewActivityLogCleanupTask(nil, settings, nil)) @@ -250,7 +249,7 @@ func TestTaskManagerStartPreservesExistingTriggers(t *testing.T) { triggerRepo, fakeExecutionRepository{}, newFakeTrigger, - slog.New(slog.NewTextHandler(io.Discard, nil)), + slog.New(slog.DiscardHandler), ) manager.Register(stubTask{ @@ -289,7 +288,7 @@ func TestTaskManagerRunTaskNotifiesAfterTriggerRearm(t *testing.T) { triggerRepo, fakeExecutionRepository{}, newFakeTrigger, - slog.New(slog.NewTextHandler(io.Discard, nil)), + slog.New(slog.DiscardHandler), ) manager.AddObserver(observer) manager.Register(stubTask{key: taskKey}) @@ -349,7 +348,7 @@ func TestTaskManagerTriggerSkipsConditionalTaskWithoutHistory(t *testing.T) { triggerRepo, historyRepo, factory, - slog.New(slog.NewTextHandler(io.Discard, nil)), + slog.New(slog.DiscardHandler), ) task := &conditionalStubTask{ stubTask: stubTask{key: "conditional"}, diff --git a/internal/taskmanager/tasks/refresh_metadata.go b/internal/taskmanager/tasks/refresh_metadata.go index 4611ce8e..eefd7565 100644 --- a/internal/taskmanager/tasks/refresh_metadata.go +++ b/internal/taskmanager/tasks/refresh_metadata.go @@ -180,7 +180,7 @@ func (t *RefreshMetadataTask) refreshBatch( mu.Unlock() if err != nil { - slog.Warn("refresh task: failed", + slog.WarnContext(ctx, "refresh task: failed", "component", "taskmanager", "target_type", job.candidate.TargetType, "content_id", job.candidate.ContentID, "error", err) diff --git a/internal/taskmanager/tasks/scan_libraries.go b/internal/taskmanager/tasks/scan_libraries.go index 594d95c1..afb1b1cd 100644 --- a/internal/taskmanager/tasks/scan_libraries.go +++ b/internal/taskmanager/tasks/scan_libraries.go @@ -86,7 +86,7 @@ func (t *ScanLibrariesTask) Execute(ctx context.Context, progress taskmanager.Pr created, enqueueErr := t.queuer.EnqueueLibraryScan(ctx, folder.ID, "task:scan_libraries") if enqueueErr != nil { - slog.Error("scan task: failed to enqueue library scan", "folder_id", folder.ID, "name", folder.Name, "error", enqueueErr) + slog.ErrorContext(ctx, "scan task: failed to enqueue library scan", "component", "taskmanager", "folder_id", folder.ID, "name", folder.Name, "error", enqueueErr) mu.Lock() summary.Errors++ mu.Unlock() diff --git a/internal/telemetry/config.go b/internal/telemetry/config.go new file mode 100644 index 00000000..a4342dac --- /dev/null +++ b/internal/telemetry/config.go @@ -0,0 +1,168 @@ +// Package telemetry provides the OpenTelemetry SDK bootstrap for Silo: shared +// resource, tracer provider, logger provider, and W3C propagation. It +// deliberately does NOT build or register a MeterProvider — metrics stay on the +// existing Prometheus rail. Leaving the global MeterProvider as the built-in +// no-op is what prevents the trace instrumentation libraries from double-emitting +// metrics. See docs/superpowers/plans/2026-07-02-opentelemetry-observability.md. +package telemetry + +import ( + "math" + "os" + "strconv" + "strings" +) + +// Protocol identifies the OTLP exporter wire protocol. +type Protocol string + +const ( + // ProtocolGRPC is the OTLP/gRPC protocol (default). + ProtocolGRPC Protocol = "grpc" + // ProtocolHTTP is the OTLP/HTTP+protobuf protocol. + ProtocolHTTP Protocol = "http/protobuf" +) + +// Sampler identifies the head-sampling strategy (OTEL_TRACES_SAMPLER). +type Sampler string + +const ( + // SamplerAlwaysOn samples every trace. + SamplerAlwaysOn Sampler = "always_on" + // SamplerAlwaysOff samples nothing. + SamplerAlwaysOff Sampler = "always_off" + // SamplerTraceIDRatio samples by trace-id ratio regardless of the parent. + SamplerTraceIDRatio Sampler = "traceidratio" + // SamplerParentBasedAlwaysOn honors the parent decision, sampling roots. + SamplerParentBasedAlwaysOn Sampler = "parentbased_always_on" + // SamplerParentBasedAlwaysOff honors the parent decision, dropping roots. + SamplerParentBasedAlwaysOff Sampler = "parentbased_always_off" + // SamplerParentBasedTraceIDRatio honors the parent decision, sampling roots + // by trace-id ratio. This is the default. + SamplerParentBasedTraceIDRatio Sampler = "parentbased_traceidratio" +) + +// defaultServiceName is used when OTEL_SERVICE_NAME is unset. +const defaultServiceName = "silo-server" + +// defaultSamplerRatio is the parent-based trace-id ratio applied when +// OTEL_TRACES_SAMPLER_ARG is unset or unparseable. +const defaultSamplerRatio = 1.0 + +// Config is the fully-defaulted telemetry configuration parsed from the +// environment. It is cheap to construct and safe to build even when telemetry +// is disabled. +type Config struct { + // Enabled gates the entire feature. True when SILO_OTEL_ENABLED is truthy + // OR OTEL_EXPORTER_OTLP_ENDPOINT is set. + Enabled bool + + // Endpoint is the OTLP collector endpoint (OTEL_EXPORTER_OTLP_ENDPOINT). It + // is used ONLY to decide Enabled; the exporters themselves read the endpoint + // (and all other OTEL_EXPORTER_OTLP_* knobs) directly from the environment, + // which remains the single source of truth for exporter wiring. + Endpoint string + // Protocol is the generic OTLP wire protocol (OTEL_EXPORTER_OTLP_PROTOCOL). + // It is the fallback for any signal without a signal-specific override. + Protocol Protocol + // TracesProtocol selects the trace exporter's wire protocol, honoring + // OTEL_EXPORTER_OTLP_TRACES_PROTOCOL and falling back to Protocol. + TracesProtocol Protocol + // LogsProtocol selects the log exporter's wire protocol, honoring + // OTEL_EXPORTER_OTLP_LOGS_PROTOCOL and falling back to Protocol. + LogsProtocol Protocol + + // ServiceName populates the service.name resource attribute. + ServiceName string + // ServiceVersion populates the service.version resource attribute. + ServiceVersion string + // NodeID populates the service.instance.id resource attribute. + NodeID string + + // Sampler is the head-sampling strategy (OTEL_TRACES_SAMPLER). Unrecognized + // or unsupported values (e.g. jaeger_remote) fall back to + // parentbased_traceidratio. + Sampler Sampler + // SamplerRatio is the trace-id-ratio sampling probability used by the + // ratio-based samplers (OTEL_TRACES_SAMPLER_ARG). + SamplerRatio float64 +} + +// LoadConfig parses the telemetry configuration from the environment. nodeID is +// the resolved node identity used for the service.instance.id resource +// attribute. +func LoadConfig(nodeID string) Config { + endpoint := strings.TrimSpace(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) + enabled := truthy(os.Getenv("SILO_OTEL_ENABLED")) || endpoint != "" + + serviceName := strings.TrimSpace(os.Getenv("OTEL_SERVICE_NAME")) + if serviceName == "" { + serviceName = defaultServiceName + } + + // The generic protocol is the fallback; per-signal env vars override it for + // their own exporter so mixed collector setups (e.g. HTTP logs, gRPC traces) + // work as the OTLP spec prescribes. + protocol := parseProtocol(os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"), ProtocolGRPC) + tracesProtocol := parseProtocol(os.Getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"), protocol) + logsProtocol := parseProtocol(os.Getenv("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL"), protocol) + + ratio := defaultSamplerRatio + if raw := strings.TrimSpace(os.Getenv("OTEL_TRACES_SAMPLER_ARG")); raw != "" { + // Accept only finite, non-negative values; clamp above 1 to 1.0 so a + // typo'd or +Inf arg means "sample everything" rather than silently + // falling through. NaN and -Inf fail the v >= 0 / IsInf checks. + if v, err := strconv.ParseFloat(raw, 64); err == nil && v >= 0 && !math.IsInf(v, 1) { + ratio = math.Min(v, 1.0) + } + } + + return Config{ + Enabled: enabled, + Endpoint: endpoint, + Protocol: protocol, + TracesProtocol: tracesProtocol, + LogsProtocol: logsProtocol, + ServiceName: serviceName, + ServiceVersion: strings.TrimSpace(os.Getenv("OTEL_SERVICE_VERSION")), + NodeID: nodeID, + Sampler: parseSampler(os.Getenv("OTEL_TRACES_SAMPLER")), + SamplerRatio: ratio, + } +} + +// parseSampler maps an OTEL_TRACES_SAMPLER value to a Sampler, falling back to +// parentbased_traceidratio when the value is empty, unrecognized, or names a +// sampler this bootstrap does not support (e.g. jaeger_remote). +func parseSampler(raw string) Sampler { + switch s := Sampler(strings.ToLower(strings.TrimSpace(raw))); s { + case SamplerAlwaysOn, SamplerAlwaysOff, SamplerTraceIDRatio, + SamplerParentBasedAlwaysOn, SamplerParentBasedAlwaysOff: + return s + default: + return SamplerParentBasedTraceIDRatio + } +} + +// parseProtocol maps an OTEL_EXPORTER_OTLP*_PROTOCOL value to a Protocol, +// returning fallback when the value is empty or unrecognized. +func parseProtocol(raw string, fallback Protocol) Protocol { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "http/protobuf", "http": + return ProtocolHTTP + case "grpc": + return ProtocolGRPC + default: + return fallback + } +} + +// truthy reports whether an env value should be treated as a boolean true. +func truthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go new file mode 100644 index 00000000..74f7e074 --- /dev/null +++ b/internal/telemetry/config_test.go @@ -0,0 +1,155 @@ +package telemetry + +import "testing" + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantEnabled bool + wantService string + wantProto Protocol + wantRatio float64 + }{ + { + name: "disabled by default", + env: map[string]string{}, + wantEnabled: false, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + { + name: "enabled via endpoint", + env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + { + name: "enabled via SILO_OTEL_ENABLED", + env: map[string]string{"SILO_OTEL_ENABLED": "true"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + { + name: "service name override", + env: map[string]string{"SILO_OTEL_ENABLED": "1", "OTEL_SERVICE_NAME": "custom"}, + wantEnabled: true, + wantService: "custom", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + { + name: "http protocol", + env: map[string]string{"SILO_OTEL_ENABLED": "yes", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolHTTP, + wantRatio: 1.0, + }, + { + name: "sampler ratio override", + env: map[string]string{"SILO_OTEL_ENABLED": "on", "OTEL_TRACES_SAMPLER_ARG": "0.25"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 0.25, + }, + { + name: "invalid sampler arg falls back", + env: map[string]string{"SILO_OTEL_ENABLED": "true", "OTEL_TRACES_SAMPLER_ARG": "not-a-number"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + { + name: "sampler arg above 1 clamps to 1.0", + env: map[string]string{"SILO_OTEL_ENABLED": "true", "OTEL_TRACES_SAMPLER_ARG": "5"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + { + name: "sampler arg +Inf falls back", + env: map[string]string{"SILO_OTEL_ENABLED": "true", "OTEL_TRACES_SAMPLER_ARG": "+Inf"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + { + name: "sampler arg negative falls back", + env: map[string]string{"SILO_OTEL_ENABLED": "true", "OTEL_TRACES_SAMPLER_ARG": "-0.5"}, + wantEnabled: true, + wantService: "silo-server", + wantProto: ProtocolGRPC, + wantRatio: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear all relevant env, then set the case's values. + for _, k := range []string{ + "SILO_OTEL_ENABLED", "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_SERVICE_NAME", + "OTEL_SERVICE_VERSION", "OTEL_TRACES_SAMPLER", + "OTEL_TRACES_SAMPLER_ARG", + } { + t.Setenv(k, "") + } + for k, v := range tt.env { + t.Setenv(k, v) + } + + cfg := LoadConfig("node-1") + if cfg.Enabled != tt.wantEnabled { + t.Errorf("Enabled = %v, want %v", cfg.Enabled, tt.wantEnabled) + } + if cfg.ServiceName != tt.wantService { + t.Errorf("ServiceName = %q, want %q", cfg.ServiceName, tt.wantService) + } + if cfg.Protocol != tt.wantProto { + t.Errorf("Protocol = %q, want %q", cfg.Protocol, tt.wantProto) + } + if cfg.SamplerRatio != tt.wantRatio { + t.Errorf("SamplerRatio = %v, want %v", cfg.SamplerRatio, tt.wantRatio) + } + if cfg.NodeID != "node-1" { + t.Errorf("NodeID = %q, want %q", cfg.NodeID, "node-1") + } + if cfg.Sampler != SamplerParentBasedTraceIDRatio { + t.Errorf("Sampler = %q, want default %q", cfg.Sampler, SamplerParentBasedTraceIDRatio) + } + }) + } +} + +func TestParseSampler(t *testing.T) { + tests := []struct { + raw string + want Sampler + }{ + {"", SamplerParentBasedTraceIDRatio}, + {"always_on", SamplerAlwaysOn}, + {"ALWAYS_OFF", SamplerAlwaysOff}, + {" traceidratio ", SamplerTraceIDRatio}, + {"parentbased_always_on", SamplerParentBasedAlwaysOn}, + {"parentbased_always_off", SamplerParentBasedAlwaysOff}, + {"parentbased_traceidratio", SamplerParentBasedTraceIDRatio}, + // Unsupported spec value and garbage both fall back to the default. + {"jaeger_remote", SamplerParentBasedTraceIDRatio}, + {"bogus", SamplerParentBasedTraceIDRatio}, + } + for _, tt := range tests { + if got := parseSampler(tt.raw); got != tt.want { + t.Errorf("parseSampler(%q) = %q, want %q", tt.raw, got, tt.want) + } + } +} diff --git a/internal/telemetry/loghandler.go b/internal/telemetry/loghandler.go new file mode 100644 index 00000000..e3a6e925 --- /dev/null +++ b/internal/telemetry/loghandler.go @@ -0,0 +1,87 @@ +package telemetry + +import ( + "context" + "log/slog" + + "go.opentelemetry.io/contrib/bridges/otelslog" + logapi "go.opentelemetry.io/otel/log" +) + +// NewOTelHandler builds an slog.Handler that bridges records into the OTel logs +// pipeline via otelslog, wrapped so that Handle/WithAttrs/WithGroup never +// propagate an export error. This mirrors the best-effort contract of the +// existing internal/logsink/opslog handlers: a failure to export must not break +// the handler chain (e.g. the console or opslog DB branch must still run). +func NewOTelHandler(lp logapi.LoggerProvider) slog.Handler { + return &bestEffortHandler{ + inner: otelslog.NewHandler("silo-server", otelslog.WithLoggerProvider(lp)), + } +} + +// bestEffortHandler wraps a handler so its Handle never returns an error. +type bestEffortHandler struct { + inner slog.Handler +} + +func (h *bestEffortHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +func (h *bestEffortHandler) Handle(ctx context.Context, r slog.Record) error { + // Swallow export errors: telemetry is best-effort and must never break the + // fan-out chain. + _ = h.inner.Handle(ctx, r) + return nil +} + +func (h *bestEffortHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &bestEffortHandler{inner: h.inner.WithAttrs(attrs)} +} + +func (h *bestEffortHandler) WithGroup(name string) slog.Handler { + return &bestEffortHandler{inner: h.inner.WithGroup(name)} +} + +// LevelGated wraps a handler so both Enabled and Handle are gated by the shared +// level. This is REQUIRED for the OTel branch: slog.MultiHandler.Enabled ORs its +// children, so without gating, the otel branch (whose Enabled follows the +// unfiltered LoggerProvider) would make the logger emit and export Debug records +// even when the shared level is Info — a per-call allocation cost and a silent +// stderr/OTLP divergence. +func LevelGated(h slog.Handler, level slog.Leveler) slog.Handler { + return &LevelGatedHandler{inner: h, level: level} +} + +type LevelGatedHandler struct { + inner slog.Handler + level slog.Leveler +} + +func (h *LevelGatedHandler) Enabled(ctx context.Context, level slog.Level) bool { + if level < h.level.Level() { + return false + } + return h.inner.Enabled(ctx, level) +} + +func (h *LevelGatedHandler) Handle(ctx context.Context, r slog.Record) error { + if r.Level < h.level.Level() { + return nil + } + return h.inner.Handle(ctx, r) +} + +func (h *LevelGatedHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &LevelGatedHandler{inner: h.inner.WithAttrs(attrs), level: h.level} +} + +func (h *LevelGatedHandler) WithGroup(name string) slog.Handler { + return &LevelGatedHandler{inner: h.inner.WithGroup(name), level: h.level} +} + +// FanOut returns a handler that dispatches each record to both the console and +// otel handlers using the stdlib slog.MultiHandler (Go 1.26). +func FanOut(console, otel slog.Handler) slog.Handler { + return slog.NewMultiHandler(console, otel) +} diff --git a/internal/telemetry/loghandler_test.go b/internal/telemetry/loghandler_test.go new file mode 100644 index 00000000..92954372 --- /dev/null +++ b/internal/telemetry/loghandler_test.go @@ -0,0 +1,95 @@ +package telemetry + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" +) + +// captureHandler records handled messages; it is gated by an optional level. +type captureHandler struct { + level slog.Leveler + msgs *[]string +} + +func newCapture(level slog.Leveler) (*captureHandler, *[]string) { + msgs := &[]string{} + return &captureHandler{level: level, msgs: msgs}, msgs +} + +func (h *captureHandler) Enabled(_ context.Context, level slog.Level) bool { + if h.level == nil { + return true + } + return level >= h.level.Level() +} + +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + *h.msgs = append(*h.msgs, r.Message) + return nil +} + +func (h *captureHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(string) slog.Handler { return h } + +// erroringHandler always fails on Handle. +type erroringHandler struct{} + +func (erroringHandler) Enabled(context.Context, slog.Level) bool { return true } +func (erroringHandler) Handle(context.Context, slog.Record) error { return errors.New("boom") } +func (erroringHandler) WithAttrs([]slog.Attr) slog.Handler { return erroringHandler{} } +func (erroringHandler) WithGroup(string) slog.Handler { return erroringHandler{} } + +func TestFanOutForwardsToBoth(t *testing.T) { + console, consoleMsgs := newCapture(nil) + otel, otelMsgs := newCapture(nil) + + h := FanOut(console, otel) + r := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0) + if err := h.Handle(context.Background(), r); err != nil { + t.Fatalf("Handle error = %v", err) + } + if len(*consoleMsgs) != 1 || (*consoleMsgs)[0] != "hello" { + t.Errorf("console msgs = %v, want [hello]", *consoleMsgs) + } + if len(*otelMsgs) != 1 || (*otelMsgs)[0] != "hello" { + t.Errorf("otel msgs = %v, want [hello]", *otelMsgs) + } +} + +func TestFanOutSwallowsOTelError(t *testing.T) { + console, consoleMsgs := newCapture(nil) + // Wrap the erroring handler like NewOTelHandler does. + otel := &bestEffortHandler{inner: erroringHandler{}} + + h := FanOut(console, otel) + r := slog.NewRecord(time.Now(), slog.LevelInfo, "hi", 0) + if err := h.Handle(context.Background(), r); err != nil { + t.Fatalf("fan-out Handle returned error despite best-effort otel: %v", err) + } + if len(*consoleMsgs) != 1 { + t.Errorf("console did not receive record: %v", *consoleMsgs) + } +} + +func TestLevelGateDropsDebugOnBothBranches(t *testing.T) { + level := new(slog.LevelVar) + level.Set(slog.LevelInfo) + + console, consoleMsgs := newCapture(level) + otelInner, otelMsgs := newCapture(nil) // otel provider is unfiltered by default + otel := LevelGated(otelInner, level) + + logger := slog.New(FanOut(console, otel)) + logger.DebugContext(context.Background(), "debug-dropped") + logger.InfoContext(context.Background(), "info-kept") + + if len(*consoleMsgs) != 1 || (*consoleMsgs)[0] != "info-kept" { + t.Errorf("console msgs = %v, want [info-kept]", *consoleMsgs) + } + if len(*otelMsgs) != 1 || (*otelMsgs)[0] != "info-kept" { + t.Errorf("otel msgs = %v, want [info-kept] (debug must be dropped by level gate)", *otelMsgs) + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000..f4ced800 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,195 @@ +package telemetry + +import ( + "context" + "errors" + "log/slog" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + otlplog "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + otlploghttp "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + logapi "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/log/global" + lognoop "go.opentelemetry.io/otel/log/noop" + "go.opentelemetry.io/otel/propagation" + sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +// Providers holds the constructed OpenTelemetry providers. When telemetry is +// disabled, LoggerProvider is a no-op so callers can build an slog handler +// unconditionally without branching. +type Providers struct { + // LoggerProvider is the OTel logs provider. Never nil (no-op when disabled). + LoggerProvider logapi.LoggerProvider +} + +// noopShutdown is an inert shutdown used when telemetry is disabled. +func noopShutdown(context.Context) error { return nil } + +// noopProviders returns Providers backed by no-op implementations, used both +// when telemetry is disabled and on setup failure so callers always get a +// usable value. +func noopProviders() *Providers { + return &Providers{LoggerProvider: lognoop.NewLoggerProvider()} +} + +// Setup initializes the OpenTelemetry SDK from cfg. When cfg.Enabled is false it +// installs nothing (leaving all otel globals as their built-in no-ops) and +// returns a Providers holding no-op providers plus a no-op shutdown. +// +// When enabled it builds one shared resource, a composite W3C propagator, a +// TracerProvider, and a LoggerProvider, registering each as the corresponding +// otel global. It deliberately does NOT build or register a MeterProvider: +// metrics stay on Prometheus, and the built-in no-op global MeterProvider keeps +// the trace instrumentation libraries from double-emitting metrics. +// +// The returned shutdown flushes and closes all providers; it is idempotent and +// joins any errors from the underlying shutdown funcs. +// +// On failure Setup installs no globals and still returns usable no-op providers +// and a no-op shutdown alongside the error, so callers can log the error and +// keep running with telemetry disabled rather than treating it as fatal. +func Setup(ctx context.Context, cfg Config) (*Providers, func(context.Context) error, error) { + if !cfg.Enabled { + return noopProviders(), noopShutdown, nil + } + + // resource.New can return a usable, fully-merged resource together with a + // non-fatal error (e.g. ErrSchemaURLConflict when a detector's schema differs + // from ours). Per OTel guidance, use the returned resource and treat the error + // as a warning; only abort if no resource came back. This preserves the + // "telemetry is best-effort, must never brick the server" contract. + res, err := buildResource(ctx, cfg) + if err != nil { + if res == nil { + return noopProviders(), noopShutdown, err + } + slog.WarnContext(ctx, "telemetry: resource built with warnings", "component", "telemetry", "error", err) + } + + var shutdownFuncs []func(context.Context) error + + // TracerProvider. Build the exporter/provider before mutating any process + // globals so a failure leaves nothing installed. + traceExp, err := newTraceExporter(ctx, cfg) + if err != nil { + return noopProviders(), noopShutdown, err + } + tp := sdktrace.NewTracerProvider( + sdktrace.WithResource(res), + sdktrace.WithBatcher(traceExp), + sdktrace.WithSampler(newSampler(cfg)), + ) + shutdownFuncs = append(shutdownFuncs, tp.Shutdown) + + // LoggerProvider. + logExp, err := newLogExporter(ctx, cfg) + if err != nil { + // Best-effort cleanup of what we already built before failing. Nothing + // is installed as a global yet, so this only drains the trace batcher. + _ = tp.Shutdown(ctx) + return noopProviders(), noopShutdown, err + } + lp := sdklog.NewLoggerProvider( + sdklog.WithResource(res), + sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)), + ) + shutdownFuncs = append(shutdownFuncs, lp.Shutdown) + + // Both exporters constructed successfully: now install the globals. Setting + // them last preserves the "install nothing on failure" invariant. + otel.SetTracerProvider(tp) + global.SetLoggerProvider(lp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + shutdown := newIdempotentShutdown(shutdownFuncs) + + return &Providers{LoggerProvider: lp}, shutdown, nil +} + +// buildResource constructs the single shared resource describing this process. +func buildResource(ctx context.Context, cfg Config) (*resource.Resource, error) { + attrs := []attribute.KeyValue{semconv.ServiceName(cfg.ServiceName)} + if cfg.ServiceVersion != "" { + attrs = append(attrs, semconv.ServiceVersion(cfg.ServiceVersion)) + } + if cfg.NodeID != "" { + // service.instance.id is the semconv slot for per-instance identity; + // backends key on it to distinguish nodes sharing one service.name. + attrs = append(attrs, semconv.ServiceInstanceID(cfg.NodeID)) + } + return resource.New(ctx, + resource.WithFromEnv(), + resource.WithProcess(), + resource.WithHost(), + resource.WithAttributes(attrs...), + ) +} + +// newSampler maps the configured OTEL_TRACES_SAMPLER strategy to an SDK +// sampler. Unrecognized values were already normalized to the parent-based +// trace-id-ratio default by parseSampler. +func newSampler(cfg Config) sdktrace.Sampler { + switch cfg.Sampler { + case SamplerAlwaysOn: + return sdktrace.AlwaysSample() + case SamplerAlwaysOff: + return sdktrace.NeverSample() + case SamplerTraceIDRatio: + return sdktrace.TraceIDRatioBased(cfg.SamplerRatio) + case SamplerParentBasedAlwaysOn: + return sdktrace.ParentBased(sdktrace.AlwaysSample()) + case SamplerParentBasedAlwaysOff: + return sdktrace.ParentBased(sdktrace.NeverSample()) + default: + return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SamplerRatio)) + } +} + +// newTraceExporter builds the OTLP trace exporter for the configured protocol. +func newTraceExporter(ctx context.Context, cfg Config) (sdktrace.SpanExporter, error) { + if cfg.TracesProtocol == ProtocolHTTP { + return otlptracehttp.New(ctx) + } + return otlptracegrpc.New(ctx) +} + +// newLogExporter builds the OTLP log exporter for the configured protocol. +func newLogExporter(ctx context.Context, cfg Config) (sdklog.Exporter, error) { + if cfg.LogsProtocol == ProtocolHTTP { + return otlploghttp.New(ctx) + } + return otlplog.New(ctx) +} + +// newIdempotentShutdown returns a shutdown func that runs the accumulated +// shutdown funcs exactly once, joining their errors. +func newIdempotentShutdown(funcs []func(context.Context) error) func(context.Context) error { + var once sync.Once + var err error + return func(ctx context.Context) error { + once.Do(func() { + var errs []error + for _, fn := range funcs { + if fn == nil { + continue + } + if e := fn(ctx); e != nil { + errs = append(errs, e) + } + } + err = errors.Join(errs...) + }) + return err + } +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000..2b0770dd --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,71 @@ +package telemetry + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel" + lognoop "go.opentelemetry.io/otel/log/noop" +) + +func TestSetupDisabled(t *testing.T) { + // Guard: with telemetry disabled, Setup must install nothing — in + // particular the global MeterProvider must be untouched (metrics stay on + // Prometheus; the built-in no-op prevents the trace instrumentation libs + // from double-emitting). + beforeMP := otel.GetMeterProvider() + + providers, shutdown, err := Setup(context.Background(), Config{Enabled: false}) + if err != nil { + t.Fatalf("Setup(disabled) error = %v", err) + } + if providers == nil || providers.LoggerProvider == nil { + t.Fatal("Setup(disabled) returned nil providers/logger provider") + } + if _, ok := providers.LoggerProvider.(lognoop.LoggerProvider); !ok { + t.Errorf("disabled LoggerProvider = %T, want no-op", providers.LoggerProvider) + } + if got := otel.GetMeterProvider(); got != beforeMP { + t.Errorf("MeterProvider changed after disabled Setup: %T", got) + } + if err := shutdown(context.Background()); err != nil { + t.Errorf("disabled shutdown error = %v", err) + } +} + +func TestSetupEnabled(t *testing.T) { + // Use gRPC exporters with the default (unset) endpoint; the OTLP exporters + // connect lazily so Setup succeeds without a live collector. + beforeMP := otel.GetMeterProvider() + + cfg := Config{ + Enabled: true, + Protocol: ProtocolGRPC, + ServiceName: "silo-server", + NodeID: "node-test", + SamplerRatio: 1.0, + } + providers, shutdown, err := Setup(context.Background(), cfg) + if err != nil { + t.Fatalf("Setup(enabled) error = %v", err) + } + if providers == nil || providers.LoggerProvider == nil { + t.Fatal("Setup(enabled) returned nil providers") + } + if _, ok := providers.LoggerProvider.(lognoop.LoggerProvider); ok { + t.Error("enabled LoggerProvider is no-op, want real provider") + } + + // Metrics guard: still no MeterProvider registered. + if got := otel.GetMeterProvider(); got != beforeMP { + t.Errorf("MeterProvider changed after enabled Setup: %T", got) + } + + // Shutdown returns nil and is idempotent. + if err := shutdown(context.Background()); err != nil { + t.Errorf("shutdown error = %v", err) + } + if err := shutdown(context.Background()); err != nil { + t.Errorf("second shutdown error = %v", err) + } +} diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 6c925430..6285b77a 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -350,7 +350,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { session, err := playback.StartTranscode(context.WithoutCancel(r.Context()), opts) if err != nil { unlock() - slog.Error("start transcode", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) + slog.ErrorContext(r.Context(), "start transcode", "component", "transcodenode", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) http.Error(w, "failed to start transcode", http.StatusInternalServerError) return } @@ -405,7 +405,7 @@ func (s *Server) reconstructFromToken(r *http.Request, sessionID string, request cfg := s.watcher.Config() claims, err := streamtoken.Verify(tokenStr, cfg.Auth.JWTSecret) if err != nil { - slog.Warn("transcode node reconstruct: invalid stream token", "error", err, + slog.WarnContext(r.Context(), "transcode node reconstruct: invalid stream token", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID) return nil } @@ -512,7 +512,7 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe session, err := playback.StartTranscode(context.WithoutCancel(r.Context()), opts) if err != nil { - slog.Error("transcode node reconstruct start failed", "error", err, + slog.ErrorContext(r.Context(), "transcode node reconstruct start failed", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID) return nil } @@ -545,7 +545,7 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe MediaFileID: card.MediaFileID, }) - slog.Info("transcode node session reconstructed from token", + slog.InfoContext(r.Context(), "transcode node session reconstructed from token", "component", "transcodenode", "session", sessionID, "playback_session_id", sessionID, "requested_segment", requestedSegment, "start_segment_number", opts.StartSegmentNumber) return session @@ -585,7 +585,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { s.activeJobs.Add(-1) if err := session.Close(); err != nil { - slog.Error("close transcode session", "error", err, "session", sessionID, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "close transcode session", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID) } cfg := s.watcher.Config() @@ -597,7 +597,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { // must still succeed even if the recipe store is briefly unavailable. if s.recipeStore != nil { if err := s.recipeStore.Delete(r.Context(), sessionID); err != nil { - slog.Warn("delete transcode recipe on stop", "error", err, "session", sessionID, "playback_session_id", sessionID) + slog.WarnContext(r.Context(), "delete transcode recipe on stop", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID) } } @@ -626,7 +626,7 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { manifest, err := session.BuildPlaybackManifest("segment/", r.URL.RawQuery) if err != nil { - slog.Error("get manifest", "error", err, "session", sessionID, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "get manifest", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID) http.Error(w, "manifest not ready", http.StatusServiceUnavailable) return } @@ -670,7 +670,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) { if !decision.Progress.LastProducedAt.IsZero() { lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds() } - slog.Info("transcode segment missing", + slog.InfoContext(r.Context(), "transcode segment missing", "component", "transcodenode", "segment", name, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -683,7 +683,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) { "playback_session_id", sessionID, ) if decision.Wait { - slog.Info("transcode segment wait", + slog.InfoContext(r.Context(), "transcode segment wait", "component", "transcodenode", "segment", name, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -697,7 +697,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) { ) segPath, err = session.WaitForSegment(name, decision.WaitTimeout) if err != nil && err == playback.ErrSegmentNotFound { - slog.Info("transcode segment wait timeout", + slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "transcodenode", "segment", name, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -715,11 +715,11 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) { if err != nil && err == playback.ErrSegmentNotFound && decision.RestartOnTimeout { seekSeconds, ok, seekErr := session.RestartSeekTarget(segNum) if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) { - slog.Error("resolve transcode node seek target", "error", seekErr, "segment", name, "session", sessionID, "playback_session_id", sessionID) + slog.ErrorContext(r.Context(), "resolve transcode node seek target", "component", "transcodenode", "error", seekErr, "segment", name, "session", sessionID, "playback_session_id", sessionID) } if ok { - slog.Info("transcode node seek restart", + slog.InfoContext(r.Context(), "transcode node seek restart", "component", "transcodenode", "segment", name, "requested_segment", segNum, "produced_head", decision.Progress.ProducedHead, @@ -786,14 +786,14 @@ func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) { if s.recipeStore != nil { for _, id := range stopped { if err := s.recipeStore.Delete(r.Context(), id); err != nil { - slog.Warn("delete transcode recipe on force reload", "error", err, "session", id, "playback_session_id", id) + slog.WarnContext(r.Context(), "delete transcode recipe on force reload", "component", "transcodenode", "error", err, "session", id, "playback_session_id", id) } } } s.tracker.Cleanup(r.Context()) - slog.Info("transcode force reload completed") + slog.InfoContext(r.Context(), "transcode force reload completed", slog.String("component", "transcodenode")) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/usercollections/scheduler.go b/internal/usercollections/scheduler.go index a110e135..d436e5c6 100644 --- a/internal/usercollections/scheduler.go +++ b/internal/usercollections/scheduler.go @@ -55,7 +55,7 @@ func (s *Scheduler) RunOnce(ctx context.Context) (json.RawMessage, error) { return marshalResult(result), nil } - s.logger.Info("user collection sync scheduler: starting", "due", len(due)) + s.logger.InfoContext(ctx, "user collection sync scheduler: starting", "due", len(due)) var ( mu sync.Mutex @@ -72,7 +72,7 @@ func (s *Scheduler) RunOnce(ctx context.Context) (json.RawMessage, error) { } _ = g.Wait() - s.logger.Info("user collection sync scheduler: complete", + s.logger.InfoContext(ctx, "user collection sync scheduler: complete", "due", result.Due, "synced", result.Synced, "failed", result.Failed, "skipped", result.Skipped, ) @@ -120,7 +120,7 @@ func (s *Scheduler) syncOne(ctx context.Context, dc dueCollection, mu *sync.Mute defer mu.Unlock() if err != nil { result.Failed++ - s.logger.Error("user collection sync scheduler: sync failed", + s.logger.ErrorContext(ctx, "user collection sync scheduler: sync failed", "user_id", dc.UserID, "collection_id", dc.CollectionID, "duration", dur, @@ -130,7 +130,7 @@ func (s *Scheduler) syncOne(ctx context.Context, dc dueCollection, mu *sync.Mute return } result.Synced++ - s.logger.Info("user collection sync scheduler: synced", + s.logger.InfoContext(ctx, "user collection sync scheduler: synced", "user_id", dc.UserID, "collection_id", dc.CollectionID, "duration", dur, @@ -145,7 +145,7 @@ func (s *Scheduler) advanceAfterFailure(ctx context.Context, dc dueCollection, a `UPDATE user_personal_collections SET next_sync_at = $1 WHERE user_id = $2 AND id = $3`, next, dc.UserID, dc.CollectionID, ); err != nil { - s.logger.Error("user collection sync scheduler: failed to advance next_sync_at after failure", + s.logger.ErrorContext(ctx, "user collection sync scheduler: failed to advance next_sync_at after failure", "user_id", dc.UserID, "collection_id", dc.CollectionID, "error", err, diff --git a/internal/usercollections/sync.go b/internal/usercollections/sync.go index f30580c1..d576de28 100644 --- a/internal/usercollections/sync.go +++ b/internal/usercollections/sync.go @@ -530,7 +530,7 @@ func (s *Service) applyResult( updated.ItemCount = len(matched) updated.NextSyncAt = nextSyncAt - s.logger.Info("user collection synced", + s.logger.InfoContext(ctx, "user collection synced", "collection_id", collection.ID, "status", status, "matched", len(matched), diff --git a/internal/watchlist/maintainer.go b/internal/watchlist/maintainer.go index 19e71fd9..3bbad28e 100644 --- a/internal/watchlist/maintainer.go +++ b/internal/watchlist/maintainer.go @@ -68,7 +68,7 @@ func (m *Maintainer) WithListEventDispatcher(d listEventDispatcher) *Maintainer // HandleWatchedCompleted reacts to completed watches asynchronously so it never // blocks the caller's watch-recording path. -func (m *Maintainer) HandleWatchedCompleted(_ context.Context, userID int, profileID string, mediaItemIDs []string) { +func (m *Maintainer) HandleWatchedCompleted(ctx context.Context, userID int, profileID string, mediaItemIDs []string) { if m == nil || m.storeFor == nil || userID == 0 || profileID == "" || len(mediaItemIDs) == 0 { return } @@ -77,7 +77,7 @@ func (m *Maintainer) HandleWatchedCompleted(_ context.Context, userID int, profi ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() if err := m.process(ctx, userID, profileID, ids); err != nil { - slog.Warn("watchlist auto-remove failed", "user_id", userID, "profile_id", profileID, "error", err) + slog.WarnContext(ctx, "watchlist auto-remove failed", "component", "watchlist", "user_id", userID, "profile_id", profileID, "error", err) } }() } diff --git a/internal/watchsync/lists.go b/internal/watchsync/lists.go index 58c5a930..98c84cbb 100644 --- a/internal/watchsync/lists.go +++ b/internal/watchsync/lists.go @@ -573,7 +573,7 @@ func (s *Service) HandleLocalListEvent(ctx context.Context, event LocalListEvent bg, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() if err := s.processLocalListEvent(bg, event); err != nil { - slog.Warn("failed to dispatch local list provider event", "list", event.List, "change", event.Change, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err) + slog.WarnContext(ctx, "failed to dispatch local list provider event", "component", "watchsync", "list", event.List, "change", event.Change, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err) } }() return nil diff --git a/internal/watchsync/providers/simkl/provider.go b/internal/watchsync/providers/simkl/provider.go index 0b97e27c..8c3db070 100644 --- a/internal/watchsync/providers/simkl/provider.go +++ b/internal/watchsync/providers/simkl/provider.go @@ -271,7 +271,7 @@ func (p *Provider) fetchPlayback(ctx context.Context, cfg watchsync.ServerConfig } func (p *Provider) FetchHistory(ctx context.Context, cfg watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemotePlay, error) { - slog.WarnContext(ctx, "simkl full watched fetch used for export dedupe", "provider", p.Key()) + slog.WarnContext(ctx, "simkl full watched fetch used for export dedupe", "component", "watchsync", "provider", p.Key()) batch, err := p.fetchWatchedBatch(ctx, cfg, conn, false) if err != nil { return nil, err diff --git a/internal/watchsync/service.go b/internal/watchsync/service.go index 0cc8dc92..e0c6e781 100644 --- a/internal/watchsync/service.go +++ b/internal/watchsync/service.go @@ -285,7 +285,7 @@ func (s *Service) RequestManualSync(ctx context.Context, userID int, profileID s runCtx, cancel := context.WithTimeout(context.Background(), manualSyncTimeout) defer cancel() if _, err := s.syncConnectionWithRun(runCtx, conn, run); err != nil { - slog.Warn("manual watch provider sync failed", "provider", conn.Provider, "user_id", conn.UserID, "profile_id", conn.ProfileID, "error", err) + slog.WarnContext(ctx, "manual watch provider sync failed", "component", "watchsync", "provider", conn.Provider, "user_id", conn.UserID, "profile_id", conn.ProfileID, "error", err) } }() @@ -329,7 +329,7 @@ func (s *Service) HandleLocalWatchEvent(ctx context.Context, event LocalWatchEve ProfileID: event.ProfileID, Plays: plays, }); err != nil { - slog.Warn("failed to dispatch local watch provider event", "kind", event.Kind, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err) + slog.WarnContext(ctx, "failed to dispatch local watch provider event", "component", "watchsync", "kind", event.Kind, "user_id", event.UserID, "profile_id", event.ProfileID, "error", err) } }() return nil @@ -391,7 +391,7 @@ func (s *Service) recordLocalWatchEventError(ctx context.Context, conn Connectio } conn.LastError = err.Error() if _, updateErr := s.repo.UpsertConnection(ctx, conn); updateErr != nil { - slog.Warn("failed to record local watch provider event error", "provider", conn.Provider, "connection_id", conn.ID, "error", updateErr) + slog.WarnContext(ctx, "failed to record local watch provider event error", "component", "watchsync", "provider", conn.Provider, "connection_id", conn.ID, "error", updateErr) } } @@ -588,7 +588,7 @@ func (s *Service) SyncDueConnections(ctx context.Context) error { continue } if err := s.SyncConnection(ctx, conn, "scheduled"); err != nil { - slog.Warn("watch provider connection sync failed", "provider", conn.Provider, "user_id", conn.UserID, "profile_id", conn.ProfileID, "error", err) + slog.WarnContext(ctx, "watch provider connection sync failed", "component", "watchsync", "provider", conn.Provider, "user_id", conn.UserID, "profile_id", conn.ProfileID, "error", err) } } return nil @@ -846,7 +846,7 @@ func (s *Service) deferRateLimitedConnection(ctx context.Context, conn Connectio return err } } - slog.Info("watch provider sync deferred by rate limit", + slog.InfoContext(ctx, "watch provider sync deferred by rate limit", "component", "watchsync", "provider", conn.Provider, "user_id", conn.UserID, "profile_id", conn.ProfileID, "until", until, "connections_deferred", deferred) return nil diff --git a/internal/webhooksync/service.go b/internal/webhooksync/service.go index db2b7cb2..d6782fdf 100644 --- a/internal/webhooksync/service.go +++ b/internal/webhooksync/service.go @@ -67,7 +67,7 @@ func (s *Service) ListConnections(ctx context.Context, userID int) ([]Connection if _, available, err := provider.DiscoverUsers(ctx, &connections[i], nil); err == nil { connections[i].AccountDiscoveryAvailable = available if err := s.repo.SetDiscoveryAvailable(ctx, connections[i].ID, available); err != nil { - slog.Warn("webhook sync: failed to persist discovery availability", "connection_id", connections[i].ID, "error", err) + slog.WarnContext(ctx, "webhook sync: failed to persist discovery availability", "component", "webhooksync", "connection_id", connections[i].ID, "error", err) } } } @@ -244,7 +244,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req result.MediaKind = event.MediaKind } if err := s.repo.MarkWebhookReceived(ctx, conn.ID); err != nil { - slog.Warn("webhook sync: failed to mark receipt", "connection_id", conn.ID, "error", err) + slog.WarnContext(ctx, "webhook sync: failed to mark receipt", "component", "webhooksync", "connection_id", conn.ID, "error", err) } if event == nil || !event.Apply { result.Outcome = OutcomeIgnored @@ -252,7 +252,7 @@ func (s *Service) ProcessWebhook(ctx context.Context, secret string, r *http.Req return result, nil } if err := s.repo.UpsertSeenUser(ctx, conn.ID, event.UserID, event.UserName); err != nil { - slog.Warn("webhook sync: failed to upsert seen external user", "connection_id", conn.ID, "external_user_id", event.UserID, "error", err) + slog.WarnContext(ctx, "webhook sync: failed to upsert seen external user", "component", "webhooksync", "connection_id", conn.ID, "external_user_id", event.UserID, "error", err) } if mapping, err := s.repo.GetMappingByUser(ctx, conn.ID, event.UserID); err != nil { diff --git a/internal/worker/cleanup.go b/internal/worker/cleanup.go index c83780aa..626ca766 100644 --- a/internal/worker/cleanup.go +++ b/internal/worker/cleanup.go @@ -164,7 +164,7 @@ func (c *SessionCleaner) CleanStale(ctx context.Context) (int, error) { c.absPruneMu.Unlock() if abndPruneDue { if err := c.closeAbandonedABSSessions(ctx); err != nil { - slog.Warn("abs session cleanup failed", "error", err) + slog.WarnContext(ctx, "abs session cleanup failed", "component", "worker", "error", err) c.absPruneMu.Lock() if c.lastABSSessionPrune.Equal(pruneStartedAt) { c.lastABSSessionPrune = previousABSSessionPrune diff --git a/scripts/backfill-library-matching/main.go b/scripts/backfill-library-matching/main.go index 0eb3c0da..9448b418 100644 --- a/scripts/backfill-library-matching/main.go +++ b/scripts/backfill-library-matching/main.go @@ -205,7 +205,8 @@ func main() { mode = "WRITE" } - slog.Info(fmt.Sprintf("backfill: complete (%s)", mode), + slog.Info("backfill: complete", + "mode", mode, "folder_id", folderID, "groups", len(groups), "items_created", createCount,