a15e47922c
Wrap /upstream/{upstreamPath...} in the metrics middleware so activity
log entries are recorded for model-dispatched endpoints accessed through
the upstream passthrough.
- Move findModelInPath to shared.FindModelInPath and reuse it in
handleUpstream, the log monitor lookup, and FetchContext.
- Extend FetchContext to resolve the model from /upstream/<model>/...
paths without consuming the request body.
- Add isMetricsRecordPath to limit recording to the model-dispatched
endpoints that produce token usage/timings.
- Add tests for upstream metrics recording and FetchContext upstream
path resolution.
Fixes #855
234 lines
7.1 KiB
Go
234 lines
7.1 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mostlygeek/llama-swap/internal/chain"
|
|
"github.com/mostlygeek/llama-swap/internal/config"
|
|
"github.com/mostlygeek/llama-swap/internal/logmon"
|
|
"github.com/mostlygeek/llama-swap/internal/shared"
|
|
)
|
|
|
|
// NewLoggers builds the proxy, upstream, and combined (mux) log monitors,
|
|
// wiring each one's output per the logToStdout config value. The proxy and
|
|
// upstream monitors write into muxlog (rather than os.Stdout directly) so
|
|
// muxlog accumulates a combined history for the /logs endpoints, while each
|
|
// monitor keeps its own per-source history and event subscribers.
|
|
//
|
|
// Behaviour matches the legacy ProxyManager:
|
|
//
|
|
// - none: everything discarded
|
|
// - both: proxy + upstream both routed to muxlog -> stdout
|
|
// - upstream: only upstream routed to muxlog -> stdout; proxy discarded
|
|
// - proxy: only proxy routed to muxlog -> stdout; upstream discarded
|
|
//
|
|
// An empty or unrecognised value behaves like "proxy".
|
|
func NewLoggers(logToStdout string) (muxlog, proxylog, upstreamlog *logmon.Monitor) {
|
|
switch logToStdout {
|
|
case config.LogToStdoutNone:
|
|
muxlog = logmon.NewWriter(io.Discard)
|
|
proxylog = logmon.NewWriter(io.Discard)
|
|
upstreamlog = logmon.NewWriter(io.Discard)
|
|
case config.LogToStdoutBoth:
|
|
muxlog = logmon.NewWriter(os.Stdout)
|
|
proxylog = logmon.NewWriter(muxlog)
|
|
upstreamlog = logmon.NewWriter(muxlog)
|
|
case config.LogToStdoutUpstream:
|
|
muxlog = logmon.NewWriter(os.Stdout)
|
|
proxylog = logmon.NewWriter(io.Discard)
|
|
upstreamlog = logmon.NewWriter(muxlog)
|
|
default:
|
|
// config.LogToStdoutProxy, and the fallback for an unset value.
|
|
muxlog = logmon.NewWriter(os.Stdout)
|
|
proxylog = logmon.NewWriter(muxlog)
|
|
upstreamlog = logmon.NewWriter(io.Discard)
|
|
}
|
|
return muxlog, proxylog, upstreamlog
|
|
}
|
|
|
|
// handleLogs serves the historical proxy/upstream log. HTML clients are
|
|
// redirected to the UI.
|
|
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.Header.Get("Accept"), "text/html") {
|
|
http.Redirect(w, r, "/ui/", http.StatusFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write(s.muxlog.GetHistory())
|
|
}
|
|
|
|
// getLogger resolves a log monitor by id. An empty id maps to the combined
|
|
// muxlog; "proxy" and "upstream" select the respective monitors.
|
|
func (s *Server) getLogger(logMonitorID string) (*logmon.Monitor, error) {
|
|
switch logMonitorID {
|
|
case "":
|
|
return s.muxlog, nil
|
|
case "proxy":
|
|
return s.proxylog, nil
|
|
case "upstream":
|
|
return s.upstreamlog, nil
|
|
default:
|
|
if _, modelID, _, found := shared.FindModelInPath(s.cfg, "/"+logMonitorID); found {
|
|
if log, ok := s.local.ProcessLogger(modelID); ok {
|
|
return log, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("invalid logger. Use 'proxy', 'upstream' or a model's ID")
|
|
}
|
|
}
|
|
|
|
// handleLogStream tails a log monitor: it writes the history then streams live
|
|
// log data until the client disconnects or the server shuts down.
|
|
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Header().Set("Transfer-Encoding", "chunked")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
// prevent nginx from buffering streamed logs
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
logMonitorID := strings.TrimPrefix(r.PathValue("logMonitorID"), "/")
|
|
// Strip a query string if it leaked into the path segment.
|
|
if idx := strings.Index(logMonitorID, "?"); idx != -1 {
|
|
logMonitorID = logMonitorID[:idx]
|
|
}
|
|
|
|
logger, err := s.getLogger(logMonitorID)
|
|
if err != nil {
|
|
shared.SendResponse(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
shared.SendResponse(w, r, http.StatusInternalServerError, "streaming unsupported")
|
|
return
|
|
}
|
|
|
|
_, skipHistory := r.URL.Query()["no-history"]
|
|
if !skipHistory {
|
|
if history := logger.GetHistory(); len(history) != 0 {
|
|
w.Write(history)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
sendChan := make(chan []byte, 10)
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
cancelSub := logger.OnLogData(func(data []byte) {
|
|
select {
|
|
case sendChan <- data:
|
|
case <-ctx.Done():
|
|
default:
|
|
}
|
|
})
|
|
defer cancelSub()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-s.shutdownCtx.Done():
|
|
return
|
|
case data := <-sendChan:
|
|
w.Write(data)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// requestLogPathSkips lists path prefixes excluded from the access log because
|
|
// they are polled frequently and would drown out useful entries.
|
|
var requestLogPathSkips = []string{"/wol-health", "/api/performance", "/metrics"}
|
|
|
|
// statusRecorder wraps an http.ResponseWriter to capture the response status
|
|
// code and the number of body bytes written, so the access log can report
|
|
// them. Flush is forwarded so streaming handlers (SSE) still work, and Hijack
|
|
// is forwarded so httputil.ReverseProxy can upgrade websocket connections.
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
size int
|
|
}
|
|
|
|
func (sr *statusRecorder) WriteHeader(code int) {
|
|
sr.status = code
|
|
sr.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (sr *statusRecorder) Write(b []byte) (int, error) {
|
|
n, err := sr.ResponseWriter.Write(b)
|
|
sr.size += n
|
|
return n, err
|
|
}
|
|
|
|
func (sr *statusRecorder) Flush() {
|
|
if f, ok := sr.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// Hijack forwards to the underlying ResponseWriter so httputil.ReverseProxy can
|
|
// take over the connection for websocket upgrades.
|
|
func (sr *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
if hj, ok := sr.ResponseWriter.(http.Hijacker); ok {
|
|
return hj.Hijack()
|
|
}
|
|
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking")
|
|
}
|
|
|
|
// clientIP resolves the originating client address, preferring proxy headers
|
|
// over the raw connection address.
|
|
func clientIP(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if first, _, found := strings.Cut(xff, ","); found {
|
|
return strings.TrimSpace(first)
|
|
}
|
|
return strings.TrimSpace(xff)
|
|
}
|
|
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
|
return strings.TrimSpace(xr)
|
|
}
|
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
|
return host
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// CreateRequestLogMiddleware returns middleware that records one access-log
|
|
// line per request to proxylog, in the legacy format:
|
|
//
|
|
// clientIP "METHOD PATH PROTO" status bodySize "UA" duration
|
|
//
|
|
// Frequently-polled health/metrics paths are skipped. The path is captured
|
|
// before next runs because /upstream rewrites the request URL in place.
|
|
func CreateRequestLogMiddleware(proxylog *logmon.Monitor) chain.Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
for _, prefix := range requestLogPathSkips {
|
|
if strings.HasPrefix(r.URL.Path, prefix) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
start := time.Now()
|
|
ip, method, path, proto, ua := clientIP(r), r.Method, r.URL.Path, r.Proto, r.UserAgent()
|
|
|
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(rec, r)
|
|
|
|
proxylog.Infof("Request %s \"%s %s %s\" %d %d \"%s\" %v",
|
|
ip, method, path, proto, rec.status, rec.size, ua, time.Since(start))
|
|
})
|
|
}
|
|
}
|