Files
memby/server/internal/api/logcontext.go
T
2026-08-09 12:53:25 +12:00

185 lines
6.0 KiB
Go

package api
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// requestIdentity is the answer to "who did this, from where, on what build" — the
// context every log line from a request needs and no single layer holds. The middleware
// knows the route and the client headers before the session exists; [Server.authed]
// learns the viewer and the television afterwards.
//
// It is carried by pointer through the request context so the outermost middleware can
// still read what an inner layer filled in. A request is served on one goroutine and the
// handler has returned by the time the middleware reads this, so no lock is needed.
type requestIdentity struct {
component string
user string
device string
client string
protocol string
}
type identityKey struct{}
// withRequestIdentity installs an empty identity for this request and returns it.
func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) {
identity := &requestIdentity{
component: componentFor(r.URL.Path),
client: clientVersion(r),
protocol: clientProtocol(r),
}
return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity)), identity
}
func identityFrom(ctx context.Context) *requestIdentity {
identity, _ := ctx.Value(identityKey{}).(*requestIdentity)
return identity
}
// identify records who the request turned out to belong to, so every later line — the
// handler's own events and the request line the middleware writes at the end — names the
// viewer and the television rather than a token.
func identify(ctx context.Context, sess store.Session) {
identity := identityFrom(ctx)
if identity == nil {
return
}
if sess.Username != "" {
identity.user = sess.Username
}
if sess.DeviceName != "" {
identity.device = sess.DeviceName
} else if sess.DeviceID != "" {
identity.device = sess.DeviceID
}
if sess.ClientVersion != "" {
identity.client = sess.ClientVersion
}
if sess.ClientProtocol != "" {
identity.protocol = sess.ClientProtocol
}
}
func (i *requestIdentity) attrs() []any {
if i == nil {
return nil
}
attrs := make([]any, 0, 8)
if i.component != "" {
attrs = append(attrs, "component", i.component)
}
attrs = append(attrs, i.viewerAttrs()...)
if i.client != "" {
attrs = append(attrs, "client", i.client)
}
return attrs
}
// viewerAttrs names the person and the television, and only when they are known: an
// unauthenticated probe has neither, and "user=unknown" on every health check is noise.
func (i *requestIdentity) viewerAttrs() []any {
if i == nil {
return nil
}
attrs := make([]any, 0, 4)
if i.user != "" {
attrs = append(attrs, "user", i.user)
}
if i.device != "" {
attrs = append(attrs, "device", i.device)
}
return attrs
}
// loggerFor returns the request's logger: the server logger with the viewer, television,
// app build and area of the app already attached. Handlers use it so an event only has
// to say what happened, and every event from one request is attributable without the
// reader correlating lines by hand.
//
// Outside a request — a scheduled sync, a health probe — it degrades to the plain server
// logger rather than refusing to log.
func (s *Server) loggerFor(ctx context.Context) *slog.Logger {
attrs := identityFrom(ctx).attrs()
if len(attrs) == 0 {
return s.log
}
return s.log.With(attrs...)
}
// componentFor names the part of the app a request came from.
//
// It is derived from the route rather than declared by the client: the TV would have to
// thread a surface name through every repository call to report one, and the route
// already identifies the screen unambiguously — /v1/home is the launcher, an image is
// artwork for something already on screen, /v1/items/{id}/playback is the player asking
// what to play. Deriving it also means an old APK's traffic is attributed correctly.
func componentFor(path string) string {
switch {
case path == "/healthz", path == "/readyz":
return "health"
case path == "/v1/status":
return "status"
case path == "/v1/update", strings.HasPrefix(path, "/updates/"):
return "updates"
case strings.HasPrefix(path, "/v1/auth/devices"):
return "devices"
case strings.HasPrefix(path, "/v1/auth/"):
return "auth"
case path == "/v1/home", path == "/v1/features":
return "home"
case path == "/v1/preferences", path == "/v1/theme":
return "settings"
case path == "/v1/screensaver", path == "/v1/preroll":
return "screensaver"
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"), path == "/v1/library/items":
return "search"
case strings.HasPrefix(path, "/v1/requests"):
return "requests"
case strings.HasPrefix(path, "/v1/recommendations"), path == "/v1/for-you":
return "recommendations"
case strings.HasPrefix(path, "/v1/my-shows"), strings.HasPrefix(path, "/v1/notifications"):
return "my-shows"
case strings.HasPrefix(path, "/v1/playback/"), isPlaybackItemPath(path):
return "playback"
case strings.HasPrefix(path, "/v1/images/"):
return "artwork"
case strings.HasPrefix(path, "/v1/items/"):
return "details"
case strings.HasPrefix(path, "/v1/analytics/"):
return "analytics"
case strings.HasPrefix(path, "/admin"):
return "admin"
case strings.HasPrefix(path, "/hooks/"):
return "webhooks"
case strings.HasPrefix(path, "/install"), path == "/":
return "installer"
default:
return "api"
}
}
// The player's own calls hang off an item, so they are told apart from the detail page by
// their trailing segment rather than their prefix.
func isPlaybackItemPath(path string) bool {
if !strings.HasPrefix(path, "/v1/items/") {
return false
}
// Fetching a subtitle is two segments deep rather than one, and matching its trailing
// "search" on its own would claim any future per-item search as playback. A seek
// preview is the same shape: the frame number is the last segment, not the word.
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") {
return true
}
switch path[strings.LastIndex(path, "/")+1:] {
case "playback", "next", "trailer":
return true
}
return false
}