Files
silo-server/internal/scanner/extras.go
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* 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.<Level>(...) calls to the
  slog.<Level>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 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:53:52 -04:00

390 lines
13 KiB
Go

package scanner
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/contentid"
"github.com/Silo-Server/silo-server/internal/librarykind"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/naming"
)
// extraCandidate is a walked file classified as a local extra rather than
// primary content. Extras bypass root/group inference and matching entirely:
// they bind to their parent item purely by directory structure.
type extraCandidate struct {
Path string
Kind models.ExtraKind
// SupplementalDir is the classified ancestor directory (Trailers/,
// Featurettes/, ...); empty when the file was classified by its filename
// suffix (-trailer, -behindthescenes, ...).
SupplementalDir string
}
// extrasDirAncestorDepth bounds how far above a file the walk looks for a
// supplemental directory name. Two levels covers "Movie/Extras/file.mkv" and
// "Movie/Extras/Subdir/file.mkv" without letting a library that happens to
// live inside a directory named "Extras" classify everything beneath it.
const extrasDirAncestorDepth = 2
// extrasClassifier classifies walked paths as local extras using the
// library's structure. A convention-named directory ("Other", "Trailers",
// "Extras", ...) only counts as an extras dir when it is owned by a title
// folder — a directory that holds media of its own (the movie file beside the
// extras dir, or episodes in season folders beside it). Convention names used
// as content-scope folders at any depth ("movies/other/<Movie>/...",
// "movies/4K/shorts/<Movie>/...") own no media directly and never classify,
// so the titles beneath them stay primary.
type extrasClassifier struct {
folderType string
rootSet map[string]bool
// dirFiles marks directories that directly contain a walked media file.
dirFiles map[string]bool
// dirFilesBelow marks directories with a walked media file exactly two
// levels down through a non-convention child (a show folder above its
// season folders — but not a folder whose only media hides inside its
// own extras dirs).
dirFilesBelow map[string]bool
// probeFS switches ownership checks to bounded os.ReadDir probes for
// single-file (watch event) scans, which have no walked path list.
probeFS bool
}
// newExtrasClassifier builds a classifier from a scan's walked paths.
func newExtrasClassifier(folderType string, libraryRoots []string, walkedPaths []string) *extrasClassifier {
c := &extrasClassifier{
folderType: folderType,
rootSet: walkRootSet(libraryRoots),
dirFiles: make(map[string]bool, len(walkedPaths)),
dirFilesBelow: make(map[string]bool, len(walkedPaths)),
}
for _, p := range walkedPaths {
dir := filepath.Dir(p)
c.dirFiles[dir] = true
if extrasDirKinds[normalizeScannerDirLabel(filepath.Base(dir))] == "" {
c.dirFilesBelow[filepath.Dir(dir)] = true
}
}
return c
}
// newWatchExtrasClassifier builds a classifier for single-file scans; title
// ownership is probed from the filesystem instead of a walked path list.
func newWatchExtrasClassifier(folderType string, libraryRoots []string) *extrasClassifier {
return &extrasClassifier{
folderType: folderType,
rootSet: walkRootSet(libraryRoots),
probeFS: true,
}
}
// classify reports whether the walked path is a local extra.
//
// Directory names win over filename suffixes. For non-movie libraries a file
// carrying a parseable SxxExx episode token is never an extra: series
// "Extras/SxxExx" files keep their documented season-0 mapping.
func (c *extrasClassifier) classify(path string) (extraCandidate, bool) {
candidate := extraCandidate{Path: path}
dir := filepath.Dir(path)
for depth := 0; depth < extrasDirAncestorDepth; depth++ {
label := normalizeScannerDirLabel(filepath.Base(dir))
if kind, ok := extrasDirKinds[label]; ok {
if c.titleDirOwns(dir) {
candidate.Kind = kind
candidate.SupplementalDir = dir
}
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
if candidate.SupplementalDir == "" {
kind, ok := naming.ParseExtraSuffix(path)
if !ok {
return extraCandidate{}, false
}
candidate.Kind = models.NormalizeExtraKind(kind)
}
// Preserve the documented series behavior: an episode-tokened file under
// Extras/ is a season-0 special, not an extra.
if !librarykind.IsMovie(c.folderType) {
if hints := naming.ParseFilename(path, c.folderType); hints != nil &&
hints.Type == "series" && hints.EpisodeNum > 0 {
return extraCandidate{}, false
}
}
return candidate, true
}
// titleDirOwns reports whether the matched supplemental directory is owned by
// a title folder: the first non-supplemental ancestor must not be a library
// root and must hold media of its own — directly for movie folders, or one
// level down for series folders whose episodes live in season subfolders.
func (c *extrasClassifier) titleDirOwns(supplementalDir string) bool {
owner := firstNonSupplementalAncestor(supplementalDir)
if c.rootSet[owner] {
return false
}
if c.probeFS {
depth := 1
if !librarykind.IsMovie(c.folderType) {
depth = 2
}
return c.dirHoldsMedia(owner, depth)
}
if c.dirFiles[owner] {
return true
}
return !librarykind.IsMovie(c.folderType) && c.dirFilesBelow[owner]
}
// dirHoldsMedia is the probeFS counterpart of dirFiles/dirFilesBelow: it
// reports whether dir holds a media file within depth levels, without
// descending into convention-named subdirectories.
func (c *extrasClassifier) dirHoldsMedia(dir string, depth int) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
mode := walkModeFor(c.folderType)
for _, entry := range entries {
if entry.IsDir() {
if depth > 1 && extrasDirKinds[normalizeScannerDirLabel(entry.Name())] == "" &&
c.dirHoldsMedia(filepath.Join(dir, entry.Name()), depth-1) {
return true
}
continue
}
if mode.acceptsExt(strings.ToLower(filepath.Ext(entry.Name()))) {
return true
}
}
return false
}
// firstNonSupplementalAncestor walks up from a supplemental directory past any
// chained convention names ("Extras/Behind The Scenes/") and returns the
// cleaned directory that owns the supplemental chain.
func firstNonSupplementalAncestor(supplementalDir string) string {
dir := filepath.Dir(filepath.Clean(supplementalDir))
for extrasDirKinds[normalizeScannerDirLabel(filepath.Base(dir))] != "" {
next := filepath.Dir(dir)
if next == dir {
break
}
dir = next
}
return dir
}
// walkRootSet builds the cleaned-path set used for scope checks against the
// library's configured roots.
func walkRootSet(roots []string) map[string]bool {
set := make(map[string]bool, len(roots))
for _, root := range roots {
set[filepath.Clean(root)] = true
}
return set
}
// partitionExtraPaths splits walked paths into primary content and extras.
// Primary paths feed the existing root/group inference and matching pipeline
// untouched; extras are processed separately and never influence identity.
func partitionExtraPaths(paths []string, folderType string, libraryRoots []string) ([]string, []extraCandidate) {
classifier := newExtrasClassifier(folderType, libraryRoots, paths)
primary := paths[:0:0]
var extras []extraCandidate
for _, p := range paths {
if candidate, ok := classifier.classify(p); ok {
extras = append(extras, candidate)
continue
}
primary = append(primary, p)
}
return primary, extras
}
// extrasScanStats aggregates processExtraFiles outcomes for the scan result.
type extrasScanStats struct {
New int
Updated int
Unchanged int
Skipped int
Errors int
}
// processExtraFiles ingests classified extras: bind to a parent item, upsert
// the media_extras entity, and upsert the backing media_files row (probe data
// included) with extra_id set and content/episode ids cleared. Files whose
// parent cannot be resolved yet (parent unmatched or ambiguous) are skipped;
// the next scan retries once the parent has a content id.
func (s *Scanner) processExtraFiles(
ctx context.Context,
folder *models.MediaFolder,
extras []extraCandidate,
existingByPath map[string]*scanStateFile,
) extrasScanStats {
var stats extrasScanStats
if len(extras) == 0 || s.extraRepo == nil {
return stats
}
// Parent binding is scoped by the library's configured roots, not the
// (possibly narrower) walk roots of a scoped scan: a movie folder targeted
// directly by a subtree scan must still bind its own extras.
rootSet := walkRootSet(folder.Paths)
for _, candidate := range extras {
if ctx.Err() != nil {
return stats
}
info, err := os.Stat(candidate.Path)
if err != nil {
slog.WarnContext(ctx, "scanner: extra stat failed", "component", "scanner", "path", candidate.Path, "error", err)
stats.Errors++
continue
}
extraID := contentid.ForLocal(candidate.Path)
parentID, err := s.resolveExtraParent(ctx, folder.ID, candidate, rootSet)
if err != nil {
slog.WarnContext(ctx, "scanner: extra parent lookup failed", "component", "scanner", "path", candidate.Path, "error", err)
stats.Errors++
continue
}
if parentID == "" {
slog.DebugContext(ctx, "scanner: extra parent unresolved, deferring", "component", "scanner",
"path", candidate.Path, "kind", candidate.Kind)
stats.Skipped++
continue
}
// Upsert the entity before the unchanged check so parent/kind/title
// converge on every scan (a rematched parent or reclassified kind
// must not be masked by an unchanged file).
if err := s.extraRepo.Upsert(ctx, models.MediaExtra{
ContentID: extraID,
ParentID: parentID,
Kind: candidate.Kind,
Title: naming.ExtraTitleFromFile(candidate.Path),
}); err != nil {
slog.WarnContext(ctx, "scanner: extra upsert failed", "component", "scanner", "path", candidate.Path, "error", err)
stats.Errors++
continue
}
fileModifiedAt := normalizeFileModifiedAt(info.ModTime())
existing := existingByPath[candidate.Path]
if existing != nil && existing.ExtraID == extraID &&
existing.FileSize == info.Size() &&
existing.FileModifiedAt != nil && existing.FileModifiedAt.Equal(fileModifiedAt) &&
existing.ProbeUpdatedAt != nil && existing.MissingSince == nil {
stats.Unchanged++
continue
}
hints := s.gatherHints(candidate.Path)
probe, probeSource := s.probeFile(ctx, candidate.Path)
mf := models.MediaFile{
MediaFolderID: folder.ID,
FilePath: candidate.Path,
FileSize: info.Size(),
FileModifiedAt: &fileModifiedAt,
FileHash: hints.FileHash,
ExtraID: extraID,
}
if probe != nil {
applyProbeData(&mf, probe, probeSource)
}
if mf.SubtitleTracks == nil {
mf.SubtitleTracks = []models.SubtitleTrack{}
}
if mf.ExternalSubtitles == nil {
mf.ExternalSubtitles = []models.ExternalSubtitle{}
}
// The upsert clears content/episode linkage atomically when extra_id
// 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.WarnContext(ctx, "scanner: extra file upsert failed", "component", "scanner", "path", candidate.Path, "error", err)
stats.Errors++
continue
}
if existing == nil {
stats.New++
} else {
stats.Updated++
}
}
if stats.New+stats.Updated+stats.Skipped+stats.Errors > 0 {
slog.InfoContext(ctx, "scanner: processed extras", "component", "scanner",
"folder_id", folder.ID,
"new", stats.New,
"updated", stats.Updated,
"unchanged", stats.Unchanged,
"deferred", stats.Skipped,
"errors", stats.Errors,
)
}
return stats
}
// resolveExtraParent finds the content id of the item owning an extra.
//
// Directory-classified extras bind to the directory containing the
// supplemental folder ("Movie (2020)/" for "Movie (2020)/Extras/x.mkv"),
// requiring that directory to hold exactly one item. Suffix-classified files
// first try the sibling primary file sharing their stem ("Movie A.mkv" for
// "Movie A-trailer.mkv"), so flat multi-movie folders bind correctly, then
// fall back to the unambiguous-directory rule. Library roots never bind.
func (s *Scanner) resolveExtraParent(
ctx context.Context,
folderID int,
candidate extraCandidate,
rootSet map[string]bool,
) (string, error) {
if candidate.SupplementalDir == "" {
dir := filepath.Dir(candidate.Path)
stem := strings.TrimSuffix(filepath.Base(candidate.Path), filepath.Ext(candidate.Path))
if idx := strings.LastIndexAny(stem, "-."); idx > 0 {
stem = strings.TrimSpace(stem[:idx])
}
if stem != "" {
parentID, err := s.fileRepo.FindParentContentIDForStem(ctx, folderID, dir, stem)
if err != nil {
return "", err
}
if parentID != "" {
return parentID, nil
}
}
if rootSet[filepath.Clean(dir)] {
return "", nil
}
return s.fileRepo.FindUnambiguousParentContentIDForDir(ctx, folderID, dir)
}
parentDir := firstNonSupplementalAncestor(candidate.SupplementalDir)
if rootSet[filepath.Clean(parentDir)] {
// Supplemental dir sits at the library root — no single owner.
return "", nil
}
return s.fileRepo.FindUnambiguousParentContentIDForDir(ctx, folderID, parentDir)
}