Files
memby/server/internal/api/logcontext.go
T

265 lines
8.8 KiB
Go
Raw Normal View History

2026-08-06 22:33:56 +12:00
package api
import (
"context"
2026-08-14 11:47:32 +12:00
"crypto/rand"
"encoding/hex"
2026-08-06 22:33:56 +12:00
"log/slog"
2026-08-12 13:08:53 +12:00
"net"
2026-08-06 22:33:56 +12:00
"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 {
2026-08-14 11:47:32 +12:00
component string
2026-08-15 09:23:26 +12:00
userID string
2026-08-14 11:47:32 +12:00
user string
2026-08-20 15:06:00 +12:00
viewer string
2026-08-14 11:47:32 +12:00
device string
client string
protocol string
correlation string
2026-08-06 22:33:56 +12:00
}
type identityKey struct{}
// withRequestIdentity installs an empty identity for this request and returns it.
func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) {
identity := &requestIdentity{
2026-08-14 11:47:32 +12:00
component: componentFor(r.URL.Path),
client: clientVersion(r),
protocol: clientProtocol(r),
correlation: requestCorrelation(r),
2026-08-06 22:33:56 +12:00
}
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
}
2026-08-15 09:23:26 +12:00
identity.userID = sess.EmbyUserID
2026-08-06 22:33:56 +12:00
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
}
}
2026-08-20 15:06:00 +12:00
// identifyViewer names the person watching, where that is somebody other than the account
// itself. A main viewer is deliberately not recorded: its name is already the "user" field,
// and printing it twice on every line would say nothing.
func identifyViewer(ctx context.Context, viewer store.Viewer) {
identity := identityFrom(ctx)
if identity == nil || viewer.IsMain() {
return
}
if viewer.Name != "" {
identity.viewer = viewer.Name
} else {
identity.viewer = viewer.ID
}
}
2026-08-06 22:33:56 +12:00
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)
}
2026-08-14 11:47:32 +12:00
if i.correlation != "" {
attrs = append(attrs, "correlation", i.correlation)
}
2026-08-06 22:33:56 +12:00
return attrs
}
2026-08-14 11:47:32 +12:00
// requestCorrelation accepts a client-supplied safe identifier or creates one at the
// gateway boundary. The same value is attached to every log line within the exchange and
// returned to the client, making a playback launch traceable through gateway and Emby work.
func requestCorrelation(r *http.Request) string {
if value := strings.TrimSpace(r.Header.Get("X-Memby-Correlation")); len(value) >= 6 && len(value) <= 64 {
for _, rune := range value {
if !(rune >= 'a' && rune <= 'z' || rune >= 'A' && rune <= 'Z' || rune >= '0' && rune <= '9' || rune == '-' || rune == '_') {
return newCorrelation()
}
}
return value
}
return newCorrelation()
}
func newCorrelation() string {
var raw [4]byte
if _, err := rand.Read(raw[:]); err != nil {
return "request-unknown"
}
return "req-" + strings.ToUpper(hex.EncodeToString(raw[:]))
}
2026-08-06 22:33:56 +12:00
// 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)
}
2026-08-20 15:06:00 +12:00
if i.viewer != "" {
attrs = append(attrs, "viewer", i.viewer)
}
2026-08-06 22:33:56 +12:00
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"
2026-08-09 08:25:50 +12:00
case path == "/v1/preferences", path == "/v1/theme":
2026-08-06 22:33:56 +12:00
return "settings"
case path == "/v1/screensaver", path == "/v1/preroll":
return "screensaver"
2026-08-09 12:53:25 +12:00
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"), path == "/v1/library/items":
2026-08-06 22:33:56 +12:00
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"
2026-08-17 11:41:36 +12:00
// Magic is a recommendation by machinery but a playback control by surface, and this
// names the part of the app a call came from: it is only ever pressed in the player.
case strings.HasPrefix(path, "/v1/playback/"), path == "/v1/magic", isPlaybackItemPath(path):
2026-08-06 22:33:56 +12:00
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
2026-08-07 10:44:17 +12:00
// "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.
2026-08-12 13:08:53 +12:00
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") ||
strings.Contains(path, "/trailers") {
2026-08-06 22:33:56 +12:00
return true
}
switch path[strings.LastIndex(path, "/")+1:] {
case "playback", "next", "trailer":
return true
}
return false
}
2026-08-12 13:08:53 +12:00
// requestClientIP is the viewer-facing address recorded for trailer playback. The first
// Forwarded address is the original client when the gateway is behind its normal reverse
// proxy; direct deployments fall back to RemoteAddr. This value is for operational logs,
// never authentication or access control.
func requestClientIP(r *http.Request) string {
for _, value := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil {
return ip.String()
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil {
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.RemoteAddr)); ip != nil {
return ip.String()
}
return "unknown"
}