feat(artwork): trace external agents at the gate seam

This commit is contained in:
Deluan 2026-08-14 12:41:18 -04:00
commit 90a7cb4706
2 changed files with 104 additions and 0 deletions

View file

@ -2,6 +2,8 @@ package artwork
import (
"context"
"errors"
"io"
"slices"
"sync"
)
@ -55,3 +57,33 @@ func traceFrom(ctx context.Context) *chainTrace {
t, _ := ctx.Value(traceCtxKey{}).(*chainTrace)
return t
}
var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)")
// tracingGate records each external agent's outcome without changing what the gate returns.
func tracingGate(t *chainTrace, inner gateFunc) gateFunc {
return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
r, path, err := inner(name, f)
candidate := "external:" + name
switch {
case r != nil:
t.add(traceStep{Candidate: candidate, Outcome: outcomeHit, Detail: path})
case errors.Is(err, errBreakerOpen):
t.add(traceStep{Candidate: candidate, Outcome: outcomeSkipped, Detail: "circuit breaker open"})
case isTransientExternal(err):
t.add(traceStep{Candidate: candidate, Outcome: outcomeError, Detail: err.Error()})
default:
t.add(traceStep{Candidate: candidate, Outcome: outcomeMiss})
}
return r, path, err
}
}
// offlineGate reports which agents would be asked without asking them, so a diagnostic
// command cannot add load to a provider that is already rate-limiting us.
func offlineGate(t *chainTrace) gateFunc {
return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
t.add(traceStep{Candidate: "external:" + name, Outcome: outcomeWouldTry})
return nil, "", errOfflineSkipped
}
}

View file

@ -2,9 +2,12 @@ package artwork
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -102,6 +105,75 @@ var _ = Describe("chainState tracing", func() {
})
})
var _ = Describe("external gate tracing", func() {
hit := func() (io.ReadCloser, string, error) {
return io.NopCloser(strings.NewReader("x")), "http://img", nil
}
miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound }
boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") }
It("records a hit with the image path", func() {
t := &chainTrace{}
g := tracingGate(t, passthroughGate)
r, _, err := g("deezer", hit)
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(t.Steps()).To(Equal([]traceStep{
{Candidate: "external:deezer", Outcome: outcomeHit, Detail: "http://img"},
}))
})
It("records a miss for a not-found", func() {
t := &chainTrace{}
_, _, _ = tracingGate(t, passthroughGate)("deezer", miss)
Expect(t.Steps()[0].Outcome).To(Equal(outcomeMiss))
})
It("records a miss for a model not-found", func() {
t := &chainTrace{}
notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound }
_, _, _ = tracingGate(t, passthroughGate)("deezer", notFound)
Expect(t.Steps()[0].Outcome).To(Equal(outcomeMiss),
"both not-found flavours are definitive answers, not faults")
})
It("records an error with its reason", func() {
t := &chainTrace{}
_, _, _ = tracingGate(t, passthroughGate)("apple-music", boom)
Expect(t.Steps()[0].Outcome).To(Equal(outcomeError))
Expect(t.Steps()[0].Detail).To(ContainSubstring("429"))
})
It("records skipped when the breaker is open", func() {
t := &chainTrace{}
open := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
return nil, "", errBreakerOpen
}
_, _, _ = tracingGate(t, open)("apple-music", hit)
Expect(t.Steps()[0].Outcome).To(Equal(outcomeSkipped))
Expect(t.Steps()[0].Detail).To(ContainSubstring("circuit breaker"))
})
It("never calls the agent in offline mode", func() {
t := &chainTrace{}
called := false
counting := func() (io.ReadCloser, string, error) {
called = true
return hit()
}
_, _, err := offlineGate(t)("deezer", counting)
Expect(called).To(BeFalse(), "offline mode must not perform external requests")
Expect(err).To(MatchError(errOfflineSkipped))
Expect(t.Steps()).To(Equal([]traceStep{
{Candidate: "external:deezer", Outcome: outcomeWouldTry},
}))
})
})
var _ = Describe("resolveAlbum tracing", func() {
var (
ctx context.Context