perf(http): gzip responses, cache hashed assets, bound the trace endpoints (#11056)

Three measured HTTP-layer regressions on a live deployment, fixed together
because they all shape the bytes on the wire.

1. No compression. The server sent no Content-Encoding regardless of what
   the client asked for, confirmed with curl straight at 127.0.0.1:8080 so
   it was not an ingress artefact. Adds gzip middleware, on by default and
   configurable via LOCALAI_DISABLE_HTTP_COMPRESSION and
   LOCALAI_HTTP_COMPRESSION_MIN_LENGTH (default 1024 bytes so tiny bodies
   are not wastefully wrapped). Streaming routes are skipped explicitly:
   an SSE Accept header, a WebSocket upgrade, and the completion / SSE /
   log-tail path prefixes, because whether a completion request streams is
   decided by the request body, which the middleware runs too early to see.
   Already-compressed formats (woff2, png, mp4, ...) are skipped too; gzip
   made those marginally larger. Measured over the embedded React build:
   JS+CSS 2815 KB raw to 808 KB gzipped (3.48x).

2. No cache headers on content-hashed assets. Vite hashes the filenames,
   so a given /assets/ URL can never change content, yet they shipped with
   no Cache-Control, ETag or Last-Modified, and the browser re-fetched the
   whole bundle on every navigation with no conditional request available.
   /assets/* now carries public, max-age=31536000, immutable. index.html
   stays no-cache so a deploy is picked up, and the unhashed locale JSONs
   get a short TTL rather than the immutable one.

3. Unbounded trace endpoints. /api/traces returned 21,033,606 bytes in
   4.65s and /api/backend-traces 3,471,682 bytes in 1.50s, and the admin
   UI polls both every few seconds. The ring buffer holds up to 1024
   entries, each embedding full input_text payloads. Both list endpoints
   now take limit / offset / full, default to 50 entries, and strip the
   heavy fields (request and response bodies plus headers for API traces,
   body and data for backend traces) unless full=true. Every trace gets a
   process-lifetime ID and GET /api/traces/{id} and
   /api/backend-traces/{id} serve the full record, which is what the UI
   fetches when a row is expanded. The list body stays a JSON array;
   paging metadata rides in X-Total-Count, X-Trace-Offset and
   X-Trace-Limit. Reproducing the live shape in a test, the polled payload
   goes from 21,131,097 bytes to 7,201 bytes.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler's LocalAI [bot]
2026-07-22 22:51:25 +02:00
committed by GitHub
parent 8eb8376596
commit ff299df453
24 changed files with 1439 additions and 49 deletions

View File

@@ -69,6 +69,8 @@ type RunCMD struct {
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
DisableCSRF bool `env:"LOCALAI_DISABLE_CSRF" help:"Disable CSRF middleware (enabled by default)" group:"api"`
DisableHTTPCompression bool `env:"LOCALAI_DISABLE_HTTP_COMPRESSION" default:"false" help:"Disable gzip compression of HTTP responses (enabled by default). Streaming endpoints are never compressed" group:"api"`
HTTPCompressionMinLength int `env:"LOCALAI_HTTP_COMPRESSION_MIN_LENGTH" default:"1024" help:"Minimum response size in bytes before gzip compression is applied" group:"api"`
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
@@ -279,6 +281,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
config.WithCors(r.CORS),
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
config.WithDisableCSRF(r.DisableCSRF),
config.WithDisableHTTPCompression(r.DisableHTTPCompression),
config.WithHTTPCompressionMinLength(r.HTTPCompressionMinLength),
config.WithThreads(r.Threads),
config.WithUploadLimitMB(r.UploadLimit),
config.WithApiKeys(r.APIKeys),

View File

@@ -50,7 +50,14 @@ type ApplicationConfig struct {
DynamicConfigsDirPollInterval time.Duration
CORS bool
DisableCSRF bool
PreloadJSONModels string
// DisableHTTPCompression turns off the gzip response middleware. Gzip is
// on by default because the React UI bundle and the admin JSON APIs are
// text-heavy; turn it off only when a fronting proxy already compresses.
DisableHTTPCompression bool
// HTTPCompressionMinLength is the response-size floor (bytes) below which
// gzip is skipped. 0 keeps middleware.DefaultCompressionMinLength.
HTTPCompressionMinLength int
PreloadJSONModels string
PreloadModelsFromPath string
CORSAllowOrigins string
ApiKeys []string
@@ -382,6 +389,18 @@ func WithDisableCSRF(b bool) AppOption {
}
}
func WithDisableHTTPCompression(b bool) AppOption {
return func(o *ApplicationConfig) {
o.DisableHTTPCompression = b
}
}
func WithHTTPCompressionMinLength(n int) AppOption {
return func(o *ApplicationConfig) {
o.HTTPCompressionMinLength = n
}
}
func WithP2PToken(s string) AppOption {
return func(o *ApplicationConfig) {
o.P2PToken = s

View File

@@ -49,6 +49,12 @@ var reactUI embed.FS
var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/readyz"}
// immutableAssetCacheControl is the Cache-Control served for content-hashed
// build output. The filename changes whenever the content does, so a one-year
// TTL plus `immutable` is safe and removes both the re-download and the
// conditional revalidation round-trip.
const immutableAssetCacheControl = "public, max-age=31536000, immutable"
// applyModelLoadCooldown maps a ModelLoadCooldownError anywhere in err's chain
// to HTTP 503 with a Retry-After header (whole seconds, floor 1), so a client
// polling a model whose load recently failed backs off instead of triggering a
@@ -202,6 +208,13 @@ func API(application *application.Application) (*echo.Echo, error) {
// errors — picks them up.
e.Use(httpMiddleware.SecurityHeaders())
// Gzip responses. Registered before the tracing and handler middlewares so
// the response writer it installs sits underneath them: the trace buffer
// keeps capturing plaintext while the wire carries the compressed bytes.
if !application.ApplicationConfig().DisableHTTPCompression {
e.Use(httpMiddleware.Compression(application.ApplicationConfig().HTTPCompressionMinLength))
}
// Custom logger middleware using xlog
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -512,6 +525,10 @@ func API(application *application.Application) (*echo.Echo, error) {
if err != nil {
return c.String(http.StatusNotFound, "React UI not built")
}
// index.html names the content-hashed bundles, so it must never
// be cached: a stale copy pins the browser to the previous
// deploy's assets.
c.Response().Header().Set("Cache-Control", "no-cache")
// Inject <base href> for reverse-proxy support; baseURL comes
// from attacker-controllable Host / X-Forwarded-Host headers.
baseURL := httpMiddleware.BaseURL(c)
@@ -573,8 +590,10 @@ func API(application *application.Application) (*echo.Echo, error) {
})
// Serve React static assets (JS, CSS, etc.) and i18n locale JSONs
// from the embedded React build.
serveReactSubdir := func(subdir string) echo.HandlerFunc {
// from the embedded React build. cacheControl is stamped on every
// hit so the browser can reuse the bytes instead of re-fetching
// ~1.8 MB of bundle on each navigation.
serveReactSubdir := func(subdir, cacheControl string) echo.HandlerFunc {
return func(c echo.Context) error {
p := subdir + "/" + c.Param("*")
f, err := reactFS.Open(p)
@@ -586,14 +605,21 @@ func API(application *application.Application) (*echo.Echo, error) {
if contentType == "" {
contentType = echo.MIMEOctetStream
}
if cacheControl != "" {
c.Response().Header().Set("Cache-Control", cacheControl)
}
return c.Stream(http.StatusOK, contentType, f)
}
}
return echo.NewHTTPError(http.StatusNotFound)
}
}
e.GET("/assets/*", serveReactSubdir("assets"))
e.GET("/locales/*", serveReactSubdir("locales"))
// Vite content-hashes everything under /assets (Manage-DrwQK63f.js),
// so a given URL can never change content: cache it for a year and
// skip revalidation entirely. Locale JSONs keep stable names, so
// they only get a short TTL.
e.GET("/assets/*", serveReactSubdir("assets", immutableAssetCacheControl))
e.GET("/locales/*", serveReactSubdir("locales", "public, max-age=300"))
}
}
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

View File

@@ -1,21 +1,106 @@
package localai
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/trace"
)
// GetAPITracesEndpoint returns all API request/response traces
// DefaultTraceListLimit bounds the trace list responses. The ring buffer holds
// up to LOCALAI_TRACING_MAX_ITEMS entries (1024 in a typical deployment) and
// each one can embed a full request/response payload, so returning the whole
// buffer made /api/traces a multi-megabyte response that the admin UI
// re-fetched on every poll. Callers that genuinely want everything can pass
// limit=0.
const DefaultTraceListLimit = 50
// MaxTraceListLimit caps an explicit limit so a client cannot ask for an
// unbounded page by accident.
const MaxTraceListLimit = 1000
// tracePageParams reads the limit/offset/full query parameters. Invalid values
// fall back to the bounded defaults rather than erroring, so existing clients
// keep working.
func tracePageParams(c echo.Context) (offset, limit int, full bool) {
limit = DefaultTraceListLimit
if raw := c.QueryParam("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
limit = n
}
}
if limit > MaxTraceListLimit {
limit = MaxTraceListLimit
}
if raw := c.QueryParam("offset"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
offset = n
}
}
if raw := c.QueryParam("full"); raw != "" {
full, _ = strconv.ParseBool(raw)
}
return offset, limit, full
}
// setTracePageHeaders publishes the paging metadata out-of-band so the
// response body stays a plain JSON array for existing consumers.
func setTracePageHeaders(c echo.Context, total, offset, limit int) {
h := c.Response().Header()
h.Set("X-Total-Count", strconv.Itoa(total))
h.Set("X-Trace-Offset", strconv.Itoa(offset))
h.Set("X-Trace-Limit", strconv.Itoa(limit))
}
func traceNotFound(c echo.Context) error {
return c.JSON(http.StatusNotFound, schema.ErrorResponse{
Error: &schema.APIError{Message: "trace not found", Code: http.StatusNotFound},
})
}
// GetAPITracesEndpoint returns a bounded page of API request/response traces
// @Summary List API request/response traces
// @Description Returns captured API exchange traces (request/response pairs) in reverse chronological order
// @Description Returns a bounded, newest-first page of captured API exchange traces. Request and response bodies plus headers are omitted unless full=true; fetch them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.
// @Tags monitoring
// @Produce json
// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
// @Param offset query int false "Number of entries to skip (default 0)"
// @Param full query bool false "Include request/response bodies and headers (default false)"
// @Success 200 {object} map[string]any "Traced API exchanges"
// @Router /api/traces [get]
func GetAPITracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(200, middleware.GetTraces())
offset, limit, full := tracePageParams(c)
page, total := middleware.GetTracesPage(offset, limit)
if !full {
for i := range page {
page[i] = middleware.SummarizeExchange(page[i])
}
}
setTracePageHeaders(c, total, offset, limit)
return c.JSON(http.StatusOK, page)
}
}
// GetAPITraceEndpoint returns a single API trace with its full payload
// @Summary Get one API trace
// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response
// @Tags monitoring
// @Produce json
// @Param id path string true "Trace ID"
// @Success 200 {object} map[string]any "Traced API exchange"
// @Failure 404 {object} schema.ErrorResponse "Trace not found"
// @Router /api/traces/{id} [get]
func GetAPITraceEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
exchange, ok := middleware.GetTrace(c.Param("id"))
if !ok {
return traceNotFound(c)
}
return c.JSON(http.StatusOK, exchange)
}
}
@@ -28,20 +113,50 @@ func GetAPITracesEndpoint() echo.HandlerFunc {
func ClearAPITracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
middleware.ClearTraces()
return c.NoContent(204)
return c.NoContent(http.StatusNoContent)
}
}
// GetBackendTracesEndpoint returns all backend operation traces
// GetBackendTracesEndpoint returns a bounded page of backend operation traces
// @Summary List backend operation traces
// @Description Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order
// @Description Returns a bounded, newest-first page of captured backend traces (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.
// @Tags monitoring
// @Produce json
// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
// @Param offset query int false "Number of entries to skip (default 0)"
// @Param full query bool false "Include the body and data payloads (default false)"
// @Success 200 {object} map[string]any "Backend operation traces"
// @Router /api/backend-traces [get]
func GetBackendTracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(200, trace.GetBackendTraces())
offset, limit, full := tracePageParams(c)
page, total := trace.GetBackendTracesPage(offset, limit)
if !full {
for i := range page {
page[i] = trace.SummarizeBackendTrace(page[i])
}
}
setTracePageHeaders(c, total, offset, limit)
return c.JSON(http.StatusOK, page)
}
}
// GetBackendTraceEndpoint returns a single backend trace with its full payload
// @Summary Get one backend operation trace
// @Description Returns a single captured backend trace, including the body and data payloads omitted from the list response
// @Tags monitoring
// @Produce json
// @Param id path string true "Trace ID"
// @Success 200 {object} map[string]any "Backend operation trace"
// @Failure 404 {object} schema.ErrorResponse "Trace not found"
// @Router /api/backend-traces/{id} [get]
func GetBackendTraceEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
t, ok := trace.GetBackendTrace(c.Param("id"))
if !ok {
return traceNotFound(c)
}
return c.JSON(http.StatusOK, t)
}
}
@@ -54,6 +169,6 @@ func GetBackendTracesEndpoint() echo.HandlerFunc {
func ClearBackendTracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
trace.ClearBackendTraces()
return c.NoContent(204)
return c.NoContent(http.StatusNoContent)
}
}

View File

@@ -1,11 +1,16 @@
package localai_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/trace"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -16,8 +21,10 @@ var _ = Describe("Traces Endpoints", func() {
BeforeEach(func() {
app = echo.New()
app.GET("/api/traces", GetAPITracesEndpoint())
app.GET("/api/traces/:id", GetAPITraceEndpoint())
app.POST("/api/traces/clear", ClearAPITracesEndpoint())
app.GET("/api/backend-traces", GetBackendTracesEndpoint())
app.GET("/api/backend-traces/:id", GetBackendTraceEndpoint())
app.POST("/api/backend-traces/clear", ClearBackendTracesEndpoint())
})
@@ -52,4 +59,131 @@ var _ = Describe("Traces Endpoints", func() {
Expect(rec.Code).To(Equal(http.StatusNoContent))
})
It("advertises the paging metadata in response headers", func() {
req := httptest.NewRequest(http.MethodGet, "/api/traces", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Header().Get("X-Trace-Limit")).To(Equal(strconv.Itoa(DefaultTraceListLimit)))
Expect(rec.Header().Get("X-Trace-Offset")).To(Equal("0"))
Expect(rec.Header().Get("X-Total-Count")).ToNot(BeEmpty())
})
It("caps an oversized explicit limit", func() {
req := httptest.NewRequest(http.MethodGet, "/api/traces?limit=999999", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Header().Get("X-Trace-Limit")).To(Equal(strconv.Itoa(MaxTraceListLimit)))
})
It("404s an unknown trace ID", func() {
req := httptest.NewRequest(http.MethodGet, "/api/traces/does-not-exist", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusNotFound))
})
})
// The backend trace buffer is a package-level ring buffer, so these specs run
// in their own Describe with an explicit clear to stay independent.
var _ = Describe("Backend traces payload bounding", func() {
var app *echo.Echo
// A payload of the shape that made the live /api/backend-traces response
// 3.4 MB: every entry carries the full input text.
const heavyText = 8192
BeforeEach(func() {
app = echo.New()
app.GET("/api/backend-traces", GetBackendTracesEndpoint())
app.GET("/api/backend-traces/:id", GetBackendTraceEndpoint())
trace.ClearBackendTraces()
trace.InitBackendTracingIfEnabled(500, 0)
base := time.Now()
for i := range 200 {
trace.RecordBackendTrace(trace.BackendTrace{
// Distinct timestamps keep the newest-first ordering (and
// therefore the offset paging) deterministic.
Timestamp: base.Add(time.Duration(i) * time.Millisecond),
Type: trace.BackendTraceLLM,
ModelName: "test-model",
Summary: "summary " + strconv.Itoa(i),
Body: strings.Repeat("b", heavyText),
Data: map[string]any{"input_text": strings.Repeat("x", heavyText)},
})
}
// Recording is asynchronous through a channel; wait for the buffer.
Eventually(func() int { return len(trace.GetBackendTraces()) }).Should(Equal(200))
})
AfterEach(func() {
trace.ClearBackendTraces()
})
get := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
return rec
}
It("bounds the default list to DefaultTraceListLimit entries", func() {
rec := get("/api/backend-traces")
Expect(rec.Code).To(Equal(http.StatusOK))
var out []map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &out)).To(Succeed())
Expect(out).To(HaveLen(DefaultTraceListLimit))
Expect(rec.Header().Get("X-Total-Count")).To(Equal("200"))
})
It("omits the heavy body and data fields from list entries", func() {
rec := get("/api/backend-traces")
var out []map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &out)).To(Succeed())
Expect(out[0]).ToNot(HaveKey("body"))
Expect(out[0]["data"]).To(BeNil())
Expect(out[0]["summary"]).ToNot(BeEmpty())
Expect(out[0]["id"]).ToNot(BeEmpty())
})
It("shrinks the polled payload by orders of magnitude", func() {
bounded := get("/api/backend-traces").Body.Len()
unbounded := get("/api/backend-traces?limit=0&full=true").Body.Len()
Expect(unbounded).To(BeNumerically(">", 200*heavyText))
Expect(bounded).To(BeNumerically("<", unbounded/100))
})
It("serves the full record from the per-trace endpoint", func() {
var list []map[string]any
Expect(json.Unmarshal(get("/api/backend-traces").Body.Bytes(), &list)).To(Succeed())
id, _ := list[0]["id"].(string)
Expect(id).ToNot(BeEmpty())
rec := get("/api/backend-traces/" + id)
Expect(rec.Code).To(Equal(http.StatusOK))
var one map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &one)).To(Succeed())
Expect(one["body"]).To(HaveLen(heavyText))
data, ok := one["data"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(data["input_text"]).To(HaveLen(heavyText))
})
It("pages through the buffer with offset", func() {
var first, second []map[string]any
Expect(json.Unmarshal(get("/api/backend-traces?limit=10").Body.Bytes(), &first)).To(Succeed())
Expect(json.Unmarshal(get("/api/backend-traces?limit=10&offset=10").Body.Bytes(), &second)).To(Succeed())
Expect(first).To(HaveLen(10))
Expect(second).To(HaveLen(10))
Expect(second[0]["id"]).ToNot(Equal(first[0]["id"]))
})
})

View File

@@ -0,0 +1,113 @@
package middleware
import (
"path/filepath"
"slices"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// DefaultCompressionMinLength is the response size (in bytes) below which
// gzip is skipped. Anything smaller tends to grow once the ~20 byte gzip
// envelope and the CPU cost on both ends are accounted for.
const DefaultCompressionMinLength = 1024
// streamingPathPrefixes lists routes that either stream incrementally
// (SSE / chunked token deltas) or hand the connection off entirely
// (WebSocket, WebRTC signalling). Buffering those behind a gzip writer
// defeats incremental flushing: the client sits on an empty buffer until
// enough bytes accumulate to fill a deflate block, which reads as a hung
// stream. They are excluded by path because whether a completion request
// streams is decided by the request BODY (`"stream": true`), which the
// compression middleware runs too early to see.
// The unversioned aliases (/chat/completions, /audio/transcriptions, ...) are
// registered alongside the /v1 forms, so both spellings are listed.
var streamingPathPrefixes = []string{
"/v1/chat/completions",
"/chat/completions",
"/v1/completions",
"/completions",
"/v1/engines/",
"/v1/responses",
"/v1/messages",
"/v1/realtime",
"/v1/audio/speech",
"/audio/speech",
"/v1/audio/transcriptions",
"/audio/transcriptions",
"/api/chat",
"/api/generate",
"/api/agent/jobs",
"/api/backend-logs",
"/api/node-backend-logs",
"/ws/",
}
// streamingPathSubstrings catches SSE bridges that carry a variable
// segment before the streaming suffix, e.g. /api/agents/:name/sse.
var streamingPathSubstrings = []string{
"/sse",
"/progress",
"/stream",
"/events",
}
// precompressedExtensions are formats that already carry their own
// compression. Running deflate over them costs CPU on both ends and makes the
// response marginally LARGER (measured: woff2 font files grow by ~60 bytes),
// so they are served as-is.
var precompressedExtensions = []string{
".woff", ".woff2", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
".ico", ".mp3", ".mp4", ".webm", ".ogg", ".zip", ".gz", ".br", ".zst",
}
// skipCompression reports whether a request must bypass gzip.
func skipCompression(c echo.Context) bool {
req := c.Request()
// An explicit SSE Accept header is the strongest signal available
// before the handler runs.
if strings.Contains(req.Header.Get("Accept"), "text/event-stream") {
return true
}
// WebSocket upgrades never carry a compressible body.
if strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
return true
}
path := req.URL.Path
for _, p := range streamingPathPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}
for _, s := range streamingPathSubstrings {
if strings.Contains(path, s) {
return true
}
}
ext := strings.ToLower(filepath.Ext(path))
if ext != "" && slices.Contains(precompressedExtensions, ext) {
return true
}
return false
}
// Compression returns gzip middleware tuned for LocalAI's traffic mix. The
// React UI ships ~1.8 MB of JS/CSS that compresses to roughly a fifth of
// that, and the admin JSON endpoints are similarly text-heavy, so the win
// is large; streaming routes are excluded via skipCompression.
//
// minLength <= 0 falls back to DefaultCompressionMinLength.
func Compression(minLength int) echo.MiddlewareFunc {
if minLength <= 0 {
minLength = DefaultCompressionMinLength
}
return middleware.GzipWithConfig(middleware.GzipConfig{
Skipper: skipCompression,
MinLength: minLength,
})
}

View File

@@ -0,0 +1,116 @@
package middleware_test
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/middleware"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Compression middleware", func() {
var e *echo.Echo
// A payload comfortably above the minimum-length threshold and highly
// repetitive, so a real gzip pass shrinks it dramatically.
body := strings.Repeat("localai compresses this json payload. ", 400)
BeforeEach(func() {
e = echo.New()
e.Use(middleware.Compression(middleware.DefaultCompressionMinLength))
handler := func(c echo.Context) error {
return c.String(http.StatusOK, body)
}
e.GET("/assets/bundle.js", handler)
e.GET("/api/traces", handler)
e.GET("/v1/chat/completions", handler)
e.GET("/api/agents/demo/sse", handler)
e.GET("/api/tiny", func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
e.GET("/assets/font.woff2", handler)
})
get := func(path string, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Accept-Encoding", "gzip")
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
It("gzips a compressible static asset response", func() {
rec := get("/assets/bundle.js", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Header().Get("Content-Encoding")).To(Equal("gzip"))
Expect(rec.Body.Len()).To(BeNumerically("<", len(body)/2))
zr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes()))
Expect(err).ToNot(HaveOccurred())
decoded, err := io.ReadAll(zr)
Expect(err).ToNot(HaveOccurred())
Expect(string(decoded)).To(Equal(body))
})
It("gzips JSON API responses", func() {
rec := get("/api/traces", nil)
Expect(rec.Header().Get("Content-Encoding")).To(Equal("gzip"))
Expect(rec.Body.Len()).To(BeNumerically("<", len(body)))
})
It("does not compress streaming completion endpoints", func() {
rec := get("/v1/chat/completions", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("does not compress SSE bridges", func() {
rec := get("/api/agents/demo/sse", nil)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("does not compress a request that asks for an event stream", func() {
rec := get("/api/traces", map[string]string{"Accept": "text/event-stream"})
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("does not compress responses below the minimum length", func() {
rec := get("/api/tiny", nil)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal("ok"))
})
It("does not re-compress formats that are already compressed", func() {
rec := get("/assets/font.woff2", nil)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
It("leaves the body untouched when the client does not accept gzip", func() {
req := httptest.NewRequest(http.MethodGet, "/assets/bundle.js", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
Expect(rec.Body.String()).To(Equal(body))
})
})

View File

@@ -8,7 +8,9 @@ import (
"net"
"net/http"
"slices"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
@@ -36,6 +38,10 @@ type APIExchangeResponse struct {
}
type APIExchange struct {
// ID identifies this exchange for the lifetime of the process. The list
// endpoint returns trimmed entries; clients fetch the full payload back
// by ID from /api/traces/:id.
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Duration time.Duration `json:"duration"`
Request APIExchangeRequest `json:"request"`
@@ -55,6 +61,11 @@ var traceBuffer *circularbuffer.Queue[APIExchange]
var mu sync.Mutex
var logChan = make(chan APIExchange, 100)
var tracingMaxItems int
var traceIDSeq atomic.Uint64
func nextTraceID() string {
return strconv.FormatUint(traceIDSeq.Add(1), 10)
}
var doInitializeTracing = sync.OnceFunc(func() {
maxItems := tracingMaxItems
@@ -225,6 +236,7 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
responseBody := make([]byte, resBody.Len())
copy(responseBody, resBody.Bytes())
exchange := APIExchange{
ID: nextTraceID(),
Timestamp: startTime,
Duration: time.Since(startTime),
ClientIP: c.RealIP(),
@@ -282,6 +294,58 @@ func GetTraces() []APIExchange {
return traces
}
// GetTracesPage returns the newest-first window [offset, offset+limit) of the
// trace buffer together with the total number of buffered exchanges. A limit
// <= 0 means "no bound" and returns everything from offset onwards.
func GetTracesPage(offset, limit int) ([]APIExchange, int) {
all := GetTraces()
return window(all, offset, limit), len(all)
}
// GetTrace returns the buffered exchange with the given ID.
func GetTrace(id string) (APIExchange, bool) {
for _, t := range GetTraces() {
if t.ID == id {
return t, true
}
}
return APIExchange{}, false
}
// SummarizeExchange strips the heavy parts of an exchange: request/response
// bodies and header maps. What remains is enough to render the trace list
// (method, path, status, timing, sizes, caller), and the byte counters are
// preserved so the UI can still say how big the dropped payload was. Callers
// fetch the full record by ID when a row is expanded.
//
// This is what keeps the polling cost bounded: bodies are what made
// /api/traces a multi-megabyte response on every refresh.
func SummarizeExchange(e APIExchange) APIExchange {
e.Request.Body = nil
e.Request.Headers = nil
e.Response.Body = nil
e.Response.Headers = nil
return e
}
// window slices s to the requested page, clamping out-of-range bounds to an
// empty result rather than panicking.
func window[T any](s []T, offset, limit int) []T {
if offset < 0 {
offset = 0
}
if offset >= len(s) {
return []T{}
}
s = s[offset:]
if limit > 0 && limit < len(s) {
s = s[:limit]
}
out := make([]T, len(s))
copy(out, s)
return out
}
// ClearTraces clears the in-memory logs
func ClearTraces() {
mu.Lock()

View File

@@ -0,0 +1,145 @@
package middleware
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// /api/traces used to serialize the entire ring buffer, bodies included: a
// 1024-entry buffer of chat completions measured 21 MB on a live deployment,
// re-fetched by the admin UI every few seconds. These specs pin the two
// properties that make the poll cheap again: the list is bounded, and list
// entries carry no payload bodies.
func seedTraceBuffer(n, bodyBytes int) {
body := []byte(strings.Repeat("x", bodyBytes))
mu.Lock()
traceBuffer = circularbuffer.New[APIExchange](n)
base := time.Now()
for i := range n {
reqBody := make([]byte, len(body))
copy(reqBody, body)
resBody := make([]byte, len(body))
copy(resBody, body)
reqHeaders := http.Header{"Content-Type": {"application/json"}}
resHeaders := http.Header{"Content-Type": {"application/json"}}
traceBuffer.Enqueue(APIExchange{
ID: strconv.Itoa(i),
Timestamp: base.Add(time.Duration(i) * time.Millisecond),
Request: APIExchangeRequest{
Method: "POST", Path: "/v1/chat/completions",
Headers: &reqHeaders, Body: &reqBody, BodyBytes: len(reqBody),
},
Response: APIExchangeResponse{
Status: 200, Headers: &resHeaders, Body: &resBody, BodyBytes: len(resBody),
},
})
}
mu.Unlock()
}
var _ = Describe("Trace pagination", func() {
AfterEach(func() {
mu.Lock()
traceBuffer = nil
mu.Unlock()
})
It("returns only the requested window and reports the true total", func() {
seedTraceBuffer(200, 64)
page, total := GetTracesPage(0, 50)
Expect(total).To(Equal(200))
Expect(page).To(HaveLen(50))
})
It("walks the buffer with offset", func() {
seedTraceBuffer(20, 8)
first, _ := GetTracesPage(0, 5)
second, _ := GetTracesPage(5, 5)
Expect(first).To(HaveLen(5))
Expect(second).To(HaveLen(5))
Expect(second[0].ID).ToNot(Equal(first[0].ID))
})
It("clamps an offset past the end to an empty page instead of panicking", func() {
seedTraceBuffer(3, 8)
page, total := GetTracesPage(500, 10)
Expect(total).To(Equal(3))
Expect(page).To(BeEmpty())
})
It("returns everything when the limit is zero", func() {
seedTraceBuffer(17, 8)
page, total := GetTracesPage(0, 0)
Expect(total).To(Equal(17))
Expect(page).To(HaveLen(17))
})
It("looks a trace up by ID with its body intact", func() {
seedTraceBuffer(10, 32)
found, ok := GetTrace("4")
Expect(ok).To(BeTrue())
Expect(found.ID).To(Equal("4"))
Expect(found.Response.Body).ToNot(BeNil())
Expect(*found.Response.Body).To(HaveLen(32))
})
It("reports a miss for an unknown ID", func() {
seedTraceBuffer(2, 8)
_, ok := GetTrace("nope")
Expect(ok).To(BeFalse())
})
It("shrinks the serialized payload by dropping bodies and headers", func() {
seedTraceBuffer(100, 4096)
full, _ := GetTracesPage(0, 0)
bounded, _ := GetTracesPage(0, 50)
for i := range bounded {
bounded[i] = SummarizeExchange(bounded[i])
}
fullJSON, err := json.Marshal(full)
Expect(err).ToNot(HaveOccurred())
boundedJSON, err := json.Marshal(bounded)
Expect(err).ToNot(HaveOccurred())
Expect(len(boundedJSON)).To(BeNumerically("<", len(fullJSON)/50),
"a bounded, summarized page must be orders of magnitude smaller than the full dump")
})
It("keeps the size counters after summarizing so the UI can still report payload sizes", func() {
seedTraceBuffer(1, 1024)
page, _ := GetTracesPage(0, 1)
summary := SummarizeExchange(page[0])
Expect(summary.Request.Body).To(BeNil())
Expect(summary.Request.Headers).To(BeNil())
Expect(summary.Response.Body).To(BeNil())
Expect(summary.Response.Headers).To(BeNil())
Expect(summary.Request.BodyBytes).To(Equal(1024))
Expect(summary.Response.BodyBytes).To(Equal(1024))
Expect(summary.Request.Path).To(Equal("/v1/chat/completions"))
Expect(summary.Response.Status).To(Equal(200))
})
})

View File

@@ -51,10 +51,10 @@ function transcriptionTrace(audioWavBase64) {
}
async function openBackendTraceRow(page, traces) {
await page.route('**/api/traces', (route) => {
await page.route('**/api/traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify([]) })
})
await page.route('**/api/backend-traces', (route) => {
await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify(traces) })
})
await page.goto('/app/traces')

View File

@@ -3,7 +3,7 @@ import { test, expect } from './coverage-fixtures.js'
test.describe('Traces - Error Display', () => {
test.beforeEach(async ({ page }) => {
// Mock API traces with sample data so the table renders
await page.route('**/api/traces', (route) => {
await page.route('**/api/traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -16,7 +16,7 @@ test.describe('Traces - Error Display', () => {
})
})
// Mock backend traces with sample data
await page.route('**/api/backend-traces', (route) => {
await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -59,10 +59,10 @@ test.describe('Traces - Error Display', () => {
// uncovered, dragging UI line coverage below the regression gate.
test.describe('Traces - vector_store backend trace detail', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/traces', (route) => {
await page.route('**/api/traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
await page.route('**/api/backend-traces', (route) => {
await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([

View File

@@ -6,7 +6,7 @@ import { test, expect } from './coverage-fixtures.js'
// can tell who/what issued each request.
test.describe('Traces - API request metadata', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/traces', (route) => {
await page.route('**/api/traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -21,7 +21,7 @@ test.describe('Traces - API request metadata', () => {
]),
})
})
await page.route('**/api/backend-traces', (route) => {
await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
await page.goto('/app/traces')

View File

@@ -0,0 +1,82 @@
import { test, expect } from './coverage-fixtures.js'
// The trace list endpoints return a bounded page with the heavy request /
// response bodies stripped; the full record is fetched per trace when a row
// is expanded. Without this the admin UI polled a multi-megabyte JSON blob
// every few seconds. These specs pin both halves of the contract from the
// browser's side: the page asks for a bounded list, and expanding a row
// issues the per-trace detail request whose payload is what gets rendered.
const LIST_BODY = [
{
id: '7',
request: { method: 'POST', path: '/v1/chat/completions', body: null },
response: { status: 200, body: null, body_bytes: 65536 },
},
]
const DETAIL_BODY = {
id: '7',
request: {
method: 'POST',
path: '/v1/chat/completions',
// "hello from the request body" base64-encoded, matching the wire shape.
body: Buffer.from('hello from the request body').toString('base64'),
},
response: {
status: 200,
body: Buffer.from('hello from the response body').toString('base64'),
body_bytes: 65536,
},
client_ip: '203.0.113.9',
}
test.describe('Traces - bounded list and on-demand detail', () => {
let listUrls = []
test.beforeEach(async ({ page }) => {
listUrls = []
await page.route('**/api/traces?*', (route) => {
listUrls.push(route.request().url())
route.fulfill({
contentType: 'application/json',
headers: { 'X-Total-Count': '842' },
body: JSON.stringify(LIST_BODY),
})
})
await page.route('**/api/traces/7', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify(DETAIL_BODY) })
})
await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
headers: { 'X-Total-Count': '0' },
body: '[]',
})
})
await page.goto('/app/traces')
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
})
test('requests a bounded page rather than the whole buffer', async () => {
expect(listUrls.length).toBeGreaterThan(0)
expect(listUrls[0]).toContain('limit=')
expect(listUrls[0]).not.toContain('limit=0')
})
test('reports the server-side total, not the page length', async ({ page }) => {
// The tab counter reflects X-Total-Count (842 buffered) even though only
// one entry was returned in the page.
await expect(page.locator('button', { hasText: 'API Traces' })).toContainText('842')
})
test('fetches the full record when a row is expanded', async ({ page }) => {
await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click()
// The bodies live only in the detail response, so seeing them proves the
// per-trace fetch happened and its payload is what gets rendered.
await expect(page.locator('text=hello from the request body')).toBeVisible()
await expect(page.locator('text=hello from the response body')).toBeVisible()
await expect(page.locator('text=203.0.113.9').first()).toBeVisible()
})
})

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useOutletContext, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { tracesApi, settingsApi } from '../utils/api'
import { tracesApi, settingsApi, DEFAULT_TRACE_PAGE_SIZE } from '../utils/api'
import { formatDateTime } from '../utils/format'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
@@ -10,6 +10,10 @@ import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
import WaveformPlayer from '../components/audio/WaveformPlayer'
// How many traces the page keeps on screen. The server buffer holds far more;
// the counters next to the tab labels report the true total.
const TRACE_PAGE_SIZE = DEFAULT_TRACE_PAGE_SIZE
const AUDIO_DATA_KEYS = new Set([
'audio_wav_base64', 'audio_duration_s', 'audio_snippet_s',
'audio_sample_rate', 'audio_samples', 'audio_rms_dbfs',
@@ -373,6 +377,9 @@ export default function Traces() {
const [backendCount, setBackendCount] = useState(0)
const [loading, setLoading] = useState(true)
const [expandedRow, setExpandedRow] = useState(null)
// detail holds the full record for the currently expanded row, fetched on
// demand from /api/traces/:id (the list response omits the bodies).
const [detail, setDetail] = useState(null)
const [sort, setSort] = useState({ key: null, dir: 'asc' })
const [tracingEnabled, setTracingEnabled] = useState(null)
@@ -435,17 +442,18 @@ export default function Traces() {
}
}
// Only a bounded page is fetched, and the server strips the request /
// response bodies from list entries — the full record is pulled per row on
// expand. The unbounded form was a multi-megabyte transfer on every poll.
const fetchTraces = useCallback(async () => {
try {
const [apiData, backendData] = await Promise.all([
tracesApi.get(),
tracesApi.getBackend(),
const [apiPage, backendPage] = await Promise.all([
tracesApi.get({ limit: TRACE_PAGE_SIZE }),
tracesApi.getBackend({ limit: TRACE_PAGE_SIZE }),
])
const api = Array.isArray(apiData) ? apiData : []
const backend = Array.isArray(backendData) ? backendData : []
setApiCount(api.length)
setBackendCount(backend.length)
setTraces(activeTab === 'api' ? api : backend)
setApiCount(apiPage.total)
setBackendCount(backendPage.total)
setTraces(activeTab === 'api' ? apiPage.items : backendPage.items)
} catch (err) {
// Tracing disabled is the default state, not an error — the in-page banner covers it.
const disabled = /disabled|not enabled|404|not found/i.test(err?.message || '')
@@ -460,9 +468,31 @@ export default function Traces() {
useEffect(() => {
setLoading(true)
setExpandedRow(null)
setDetail(null)
fetchTraces()
}, [fetchTraces])
// Expanding a row pulls the full record (bodies, data fields, audio
// snippets) that the list response deliberately omits.
const toggleRow = useCallback(async (index, row) => {
if (expandedRow === index) {
setExpandedRow(null)
setDetail(null)
return
}
setExpandedRow(index)
setDetail(null)
if (!row?.id) return
try {
const full = activeTab === 'api'
? await tracesApi.getOne(row.id)
: await tracesApi.getBackendOne(row.id)
setDetail(full)
} catch {
// Fall back to the summary view; the row still renders what it has.
}
}, [expandedRow, activeTab])
// Auto-refresh every 5 seconds
useEffect(() => {
refreshRef.current = setInterval(fetchTraces, 5000)
@@ -475,14 +505,26 @@ export default function Traces() {
else await tracesApi.clearBackend()
setTraces([])
setExpandedRow(null)
setDetail(null)
addToast('Traces cleared', 'success')
} catch (err) {
addToast(`Failed to clear: ${err.message}`, 'error')
}
}
const handleExport = () => {
const blob = new Blob([JSON.stringify(traces, null, 2)], { type: 'application/json' })
// Export asks for the full payloads explicitly — the on-screen list only
// holds summaries, and an export without bodies would be useless.
const handleExport = async () => {
let rows = traces
try {
const page = activeTab === 'api'
? await tracesApi.get({ limit: TRACE_PAGE_SIZE, full: true })
: await tracesApi.getBackend({ limit: TRACE_PAGE_SIZE, full: true })
rows = page.items
} catch (err) {
addToast(`Exporting summaries only: ${err.message}`, 'error')
}
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -492,7 +534,7 @@ export default function Traces() {
}
// Reset sort + expansion when switching trace tabs (columns differ).
useEffect(() => { setSort({ key: null, dir: 'asc' }); setExpandedRow(null) }, [activeTab])
useEffect(() => { setSort({ key: null, dir: 'asc' }); setExpandedRow(null); setDetail(null) }, [activeTab])
const sortedTraces = sort.key && TRACE_SORT[sort.key]
? [...traces].sort((a, b) => sort.dir === 'asc' ? TRACE_SORT[sort.key](a, b) : TRACE_SORT[sort.key](b, a))
@@ -643,7 +685,7 @@ export default function Traces() {
<tbody>
{sortedTraces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
<tr onClick={() => toggleRow(i, trace)} style={{ cursor: 'pointer' }}>
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
@@ -658,7 +700,7 @@ export default function Traces() {
{expandedRow === i && (
<tr>
<td colSpan="6" style={{ padding: 0 }}>
<ApiTraceDetail trace={trace} />
<ApiTraceDetail trace={detail && detail.id === trace.id ? detail : trace} />
</td>
</tr>
)}
@@ -682,7 +724,7 @@ export default function Traces() {
<tbody>
{sortedTraces.map((trace, i) => (
<React.Fragment key={i}>
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
<tr onClick={() => toggleRow(i, trace)} style={{ cursor: 'pointer' }}>
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
<td><span style={typeBadgeStyle(trace.type)}>{trace.type || '-'}</span></td>
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>{formatDateTime(trace.timestamp)}</td>
@@ -700,7 +742,7 @@ export default function Traces() {
{expandedRow === i && (
<tr>
<td colSpan="7" style={{ padding: 0 }}>
<BackendTraceDetail trace={trace} />
<BackendTraceDetail trace={detail && detail.id === trace.id ? detail : trace} />
</td>
</tr>
)}

View File

@@ -210,10 +210,29 @@ export const backendLogsApi = {
}
// Traces API
//
// The list endpoints return a bounded page with the heavy request/response
// bodies stripped; the total buffered count arrives in X-Total-Count and the
// full record is fetched per trace when a row is expanded. Polling the
// unbounded form used to move tens of megabytes every few seconds.
export const DEFAULT_TRACE_PAGE_SIZE = 50
async function fetchTracePage(endpoint, { limit = DEFAULT_TRACE_PAGE_SIZE, offset = 0, full = false } = {}) {
const response = await fetch(buildUrl(endpoint, { limit, offset, full: full ? 'true' : undefined }), {
headers: { 'Content-Type': 'application/json' },
})
const items = await handleResponse(response)
const list = Array.isArray(items) ? items : []
const total = parseInt(response.headers.get('X-Total-Count') || '', 10)
return { items: list, total: Number.isNaN(total) ? list.length : total }
}
export const tracesApi = {
get: () => fetchJSON(API_CONFIG.endpoints.traces),
get: (opts) => fetchTracePage(API_CONFIG.endpoints.traces, opts),
getOne: (id) => fetchJSON(API_CONFIG.endpoints.trace(id)),
clear: () => postJSON(API_CONFIG.endpoints.clearTraces, {}),
getBackend: () => fetchJSON(API_CONFIG.endpoints.backendTraces),
getBackend: (opts) => fetchTracePage(API_CONFIG.endpoints.backendTraces, opts),
getBackendOne: (id) => fetchJSON(API_CONFIG.endpoints.backendTrace(id)),
clearBackend: () => postJSON(API_CONFIG.endpoints.clearBackendTraces, {}),
}

View File

@@ -37,8 +37,10 @@ export const API_CONFIG = {
// Traces
traces: '/api/traces',
trace: (id) => `/api/traces/${encodeURIComponent(id)}`,
clearTraces: '/api/traces/clear',
backendTraces: '/api/backend-traces',
backendTrace: (id) => `/api/backend-traces/${encodeURIComponent(id)}`,
clearBackendTraces: '/api/backend-traces/clear',
// Backend Logs

View File

@@ -224,8 +224,10 @@ func RegisterLocalAIRoutes(router *echo.Echo,
// Traces and backend logs (monitoring)
router.GET("/api/traces", localai.GetAPITracesEndpoint(), adminMiddleware)
router.GET("/api/traces/:id", localai.GetAPITraceEndpoint(), adminMiddleware)
router.POST("/api/traces/clear", localai.ClearAPITracesEndpoint(), adminMiddleware)
router.GET("/api/backend-traces", localai.GetBackendTracesEndpoint(), adminMiddleware)
router.GET("/api/backend-traces/:id", localai.GetBackendTraceEndpoint(), adminMiddleware)
router.POST("/api/backend-traces/clear", localai.ClearBackendTracesEndpoint(), adminMiddleware)
// Backend logs — standalone only (distributed mode uses node-proxied routes)
if !appConfig.Distributed.Enabled {
@@ -260,8 +262,10 @@ func RegisterLocalAIRoutes(router *echo.Echo,
"system": "/system",
"version": "/version",
"traces": "/api/traces",
"trace": "/api/traces/:id",
"traces_clear": "/api/traces/clear",
"backend_traces": "/api/backend-traces",
"backend_trace": "/api/backend-traces/:id",
"backend_traces_clear": "/api/backend-traces/clear",
}
if !appConfig.Distributed.Enabled {

View File

@@ -0,0 +1,146 @@
package http_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Vite content-hashes the React bundle filenames, so a given /assets/ URL can
// never change content. Serving those without Cache-Control meant the browser
// re-downloaded the whole ~1.8 MB bundle on every navigation, with not even a
// conditional request available to turn it into a 304. index.html carries the
// hashed filenames, so it must stay uncached or a deploy would never be
// picked up.
var _ = Describe("Static asset caching", Ordered, func() {
var (
app *echo.Echo
tmpdir string
cancel context.CancelFunc
asset string
)
BeforeAll(func() {
var err error
tmpdir, err = os.MkdirTemp("", "static-cache-")
Expect(err).ToNot(HaveOccurred())
modelDir := filepath.Join(tmpdir, "models")
Expect(os.Mkdir(modelDir, 0750)).To(Succeed())
bDir := filepath.Join(tmpdir, "backends")
Expect(os.Mkdir(bDir, 0750)).To(Succeed())
var c context.Context
c, cancel = context.WithCancel(context.Background())
systemState, err := system.GetSystemState(
system.WithBackendPath(bDir),
system.WithModelPath(modelDir),
)
Expect(err).ToNot(HaveOccurred())
appInst, err := application.New(
config.WithContext(c),
config.WithSystemState(systemState),
)
Expect(err).ToNot(HaveOccurred())
app, err = API(appInst)
Expect(err).ToNot(HaveOccurred())
// Pick a real filename out of the embedded build so the spec exercises
// the same handler path a browser would hit.
asset = firstEmbeddedAsset(app)
Expect(asset).ToNot(BeEmpty(), "the embedded React build must contain at least one asset")
})
AfterAll(func() {
cancel()
Expect(os.RemoveAll(tmpdir)).To(Succeed())
})
do := func(path string, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
return rec
}
It("serves content-hashed assets as immutable for a year", func() {
rec := do("/assets/"+asset, nil)
Expect(rec.Code).To(Equal(http.StatusOK))
cc := rec.Header().Get("Cache-Control")
Expect(cc).To(ContainSubstring("public"))
Expect(cc).To(ContainSubstring("max-age=31536000"))
Expect(cc).To(ContainSubstring("immutable"))
})
It("keeps index.html uncached so a deploy is picked up", func() {
rec := do("/app", nil)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Header().Get("Cache-Control")).To(Equal("no-cache"))
})
It("does not mark the unhashed locale files immutable", func() {
rec := do("/locales/en/common.json", nil)
if rec.Code == http.StatusOK {
Expect(rec.Header().Get("Cache-Control")).ToNot(ContainSubstring("immutable"))
}
})
It("gzips the asset when the client accepts it", func() {
plain := do("/assets/"+asset, nil)
gzipped := do("/assets/"+asset, map[string]string{"Accept-Encoding": "gzip"})
Expect(gzipped.Code).To(Equal(http.StatusOK))
// Small assets fall below the compression threshold; only assert the
// shrink when the middleware actually engaged.
if gzipped.Header().Get("Content-Encoding") == "gzip" {
Expect(gzipped.Body.Len()).To(BeNumerically("<", plain.Body.Len()))
}
Expect(plain.Header().Get("Content-Encoding")).To(BeEmpty())
})
})
// firstEmbeddedAsset asks the running app for the asset listing indirectly:
// the SPA index references its own bundles, so parsing it yields a filename
// that is guaranteed to exist in the embedded build.
func firstEmbeddedAsset(app *echo.Echo) string {
req := httptest.NewRequest(http.MethodGet, "/app", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
body, err := io.ReadAll(rec.Body)
if err != nil {
return ""
}
const marker = "/assets/"
idx := strings.Index(string(body), marker)
if idx < 0 {
return ""
}
rest := string(body)[idx+len(marker):]
end := strings.IndexAny(rest, `"'`)
if end < 0 {
return ""
}
return rest[:end]
}

View File

@@ -6,7 +6,9 @@ import (
"maps"
"math"
"slices"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
@@ -42,6 +44,10 @@ const (
)
type BackendTrace struct {
// ID identifies this trace for the lifetime of the process, so the list
// endpoint can return trimmed entries and clients can fetch the full
// record back from /api/backend-traces/:id.
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Duration time.Duration `json:"duration"`
Type BackendTraceType `json:"type"`
@@ -68,6 +74,7 @@ var (
backendMu sync.Mutex
backendLogChan = make(chan *BackendTrace, 100)
backendInitOnce sync.Once
backendTraceIDSeq atomic.Uint64
)
// backendMaxBodyBytes caps each captured string value in a BackendTrace.Data
@@ -126,6 +133,9 @@ func RecordBackendTrace(t BackendTrace) {
if t.Data != nil {
t.Data = sanitizeData(t.Data, maxBody)
}
if t.ID == "" {
t.ID = strconv.FormatUint(backendTraceIDSeq.Add(1), 10)
}
select {
case backendLogChan <- &t:
default:
@@ -241,6 +251,48 @@ func GetBackendTraces() []BackendTrace {
return traces
}
// GetBackendTracesPage returns the newest-first window
// [offset, offset+limit) of the backend trace buffer plus the total number
// of buffered traces. A limit <= 0 means "no bound".
func GetBackendTracesPage(offset, limit int) ([]BackendTrace, int) {
all := GetBackendTraces()
total := len(all)
if offset < 0 {
offset = 0
}
if offset >= total {
return []BackendTrace{}, total
}
page := all[offset:]
if limit > 0 && limit < len(page) {
page = page[:limit]
}
out := make([]BackendTrace, len(page))
copy(out, page)
return out, total
}
// GetBackendTrace returns the buffered trace with the given ID.
func GetBackendTrace(id string) (BackendTrace, bool) {
for _, t := range GetBackendTraces() {
if t.ID == id {
return t, true
}
}
return BackendTrace{}, false
}
// SummarizeBackendTrace drops the heavy fields (the full request Body and the
// Data map, which carries things like base64 audio snippets and complete
// input_text payloads) while keeping everything the trace list renders. The
// full record stays reachable by ID. Without this the list response grew to
// tens of megabytes and the UI re-fetched it every few seconds.
func SummarizeBackendTrace(t BackendTrace) BackendTrace {
t.Body = ""
t.Data = nil
return t
}
func ClearBackendTraces() {
backendMu.Lock()
if backendTraceBuffer != nil {

View File

@@ -162,8 +162,8 @@ When authentication is enabled, the following endpoints require admin role:
- `GET /backends`, `GET /backends/available`, `GET /backends/galleries`
**System & Monitoring:**
- `GET /api/traces`, `POST /api/traces/clear`
- `GET /api/backend-traces`, `POST /api/backend-traces/clear`
- `GET /api/traces`, `GET /api/traces/{id}`, `POST /api/traces/clear`
- `GET /api/backend-traces`, `GET /api/backend-traces/{id}`, `POST /api/backend-traces/clear`
- `GET /api/backend-logs/*`, `POST /api/backend-logs/*/clear`
- `GET /api/resources`, `GET /api/settings`, `POST /api/settings`
- `GET /system`, `GET /backend/monitor`, `POST /backend/shutdown`, `POST /backend/load`

View File

@@ -83,6 +83,8 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel
| `--cors` | `false` | Enable CORS (Cross-Origin Resource Sharing) | `$LOCALAI_CORS`, `$CORS` |
| `--cors-allow-origins` | | Comma-separated list of allowed CORS origins | `$LOCALAI_CORS_ALLOW_ORIGINS`, `$CORS_ALLOW_ORIGINS` |
| `--csrf` | `false` | Enable Fiber CSRF middleware | `$LOCALAI_CSRF` |
| `--disable-http-compression` | `false` | Disable gzip compression of HTTP responses. Compression is enabled by default; streaming endpoints (streaming chat completions, SSE bridges, WebSocket upgrades) and already-compressed formats are never compressed | `$LOCALAI_DISABLE_HTTP_COMPRESSION` |
| `--http-compression-min-length` | `1024` | Minimum response size in bytes before gzip compression is applied. Smaller responses are sent as-is because the gzip envelope would outweigh the saving | `$LOCALAI_HTTP_COMPRESSION_MIN_LENGTH` |
| `--upload-limit` | `15` | Default upload-limit in MB | `$LOCALAI_UPLOAD_LIMIT`, `$UPLOAD_LIMIT` |
| `--api-keys` | | List of API Keys to enable API authentication. When this is set, all requests must be authenticated with one of these API keys | `$LOCALAI_API_KEY`, `$API_KEY` |
| `--disable-webui` | `false` | Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface | `$LOCALAI_DISABLE_WEBUI`, `$DISABLE_WEBUI` |

View File

@@ -599,7 +599,7 @@ const docTemplate = `{
},
"/api/backend-traces": {
"get": {
"description": "Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order",
"description": "Returns a bounded, newest-first page of captured backend traces (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -607,6 +607,26 @@ const docTemplate = `{
"monitoring"
],
"summary": "List backend operation traces",
"parameters": [
{
"type": "integer",
"description": "Maximum entries to return (default 50, max 1000, 0 for all)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of entries to skip (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "boolean",
"description": "Include the body and data payloads (default false)",
"name": "full",
"in": "query"
}
],
"responses": {
"200": {
"description": "Backend operation traces",
@@ -632,6 +652,42 @@ const docTemplate = `{
}
}
},
"/api/backend-traces/{id}": {
"get": {
"description": "Returns a single captured backend trace, including the body and data payloads omitted from the list response",
"produces": [
"application/json"
],
"tags": [
"monitoring"
],
"summary": "Get one backend operation trace",
"parameters": [
{
"type": "string",
"description": "Trace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Backend operation trace",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Trace not found",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/api/branding": {
"get": {
"description": "Returns the configured instance name, tagline, and asset URLs. Public — no authentication required.",
@@ -1370,7 +1426,7 @@ const docTemplate = `{
},
"/api/traces": {
"get": {
"description": "Returns captured API exchange traces (request/response pairs) in reverse chronological order",
"description": "Returns a bounded, newest-first page of captured API exchange traces. Request and response bodies plus headers are omitted unless full=true; fetch them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -1378,6 +1434,26 @@ const docTemplate = `{
"monitoring"
],
"summary": "List API request/response traces",
"parameters": [
{
"type": "integer",
"description": "Maximum entries to return (default 50, max 1000, 0 for all)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of entries to skip (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "boolean",
"description": "Include request/response bodies and headers (default false)",
"name": "full",
"in": "query"
}
],
"responses": {
"200": {
"description": "Traced API exchanges",
@@ -1403,6 +1479,42 @@ const docTemplate = `{
}
}
},
"/api/traces/{id}": {
"get": {
"description": "Returns a single captured API exchange, including the request and response bodies omitted from the list response",
"produces": [
"application/json"
],
"tags": [
"monitoring"
],
"summary": "Get one API trace",
"parameters": [
{
"type": "string",
"description": "Trace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Traced API exchange",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Trace not found",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/api/voice-profiles": {
"get": {
"description": "List saved voice-cloning references without exposing filesystem paths.",

View File

@@ -596,7 +596,7 @@
},
"/api/backend-traces": {
"get": {
"description": "Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order",
"description": "Returns a bounded, newest-first page of captured backend traces (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -604,6 +604,26 @@
"monitoring"
],
"summary": "List backend operation traces",
"parameters": [
{
"type": "integer",
"description": "Maximum entries to return (default 50, max 1000, 0 for all)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of entries to skip (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "boolean",
"description": "Include the body and data payloads (default false)",
"name": "full",
"in": "query"
}
],
"responses": {
"200": {
"description": "Backend operation traces",
@@ -629,6 +649,42 @@
}
}
},
"/api/backend-traces/{id}": {
"get": {
"description": "Returns a single captured backend trace, including the body and data payloads omitted from the list response",
"produces": [
"application/json"
],
"tags": [
"monitoring"
],
"summary": "Get one backend operation trace",
"parameters": [
{
"type": "string",
"description": "Trace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Backend operation trace",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Trace not found",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/api/branding": {
"get": {
"description": "Returns the configured instance name, tagline, and asset URLs. Public — no authentication required.",
@@ -1367,7 +1423,7 @@
},
"/api/traces": {
"get": {
"description": "Returns captured API exchange traces (request/response pairs) in reverse chronological order",
"description": "Returns a bounded, newest-first page of captured API exchange traces. Request and response bodies plus headers are omitted unless full=true; fetch them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -1375,6 +1431,26 @@
"monitoring"
],
"summary": "List API request/response traces",
"parameters": [
{
"type": "integer",
"description": "Maximum entries to return (default 50, max 1000, 0 for all)",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Number of entries to skip (default 0)",
"name": "offset",
"in": "query"
},
{
"type": "boolean",
"description": "Include request/response bodies and headers (default false)",
"name": "full",
"in": "query"
}
],
"responses": {
"200": {
"description": "Traced API exchanges",
@@ -1400,6 +1476,42 @@
}
}
},
"/api/traces/{id}": {
"get": {
"description": "Returns a single captured API exchange, including the request and response bodies omitted from the list response",
"produces": [
"application/json"
],
"tags": [
"monitoring"
],
"summary": "Get one API trace",
"parameters": [
{
"type": "string",
"description": "Trace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Traced API exchange",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Trace not found",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/api/voice-profiles": {
"get": {
"description": "List saved voice-cloning references without exposing filesystem paths.",

View File

@@ -3093,8 +3093,24 @@ paths:
- monitoring
/api/backend-traces:
get:
description: Returns captured backend traces (LLM calls, embeddings, TTS, etc.)
in reverse chronological order
description: Returns a bounded, newest-first page of captured backend traces
(LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted
unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging
metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit
headers.
parameters:
- description: Maximum entries to return (default 50, max 1000, 0 for all)
in: query
name: limit
type: integer
- description: Number of entries to skip (default 0)
in: query
name: offset
type: integer
- description: Include the body and data payloads (default false)
in: query
name: full
type: boolean
produces:
- application/json
responses:
@@ -3106,6 +3122,31 @@ paths:
summary: List backend operation traces
tags:
- monitoring
/api/backend-traces/{id}:
get:
description: Returns a single captured backend trace, including the body and
data payloads omitted from the list response
parameters:
- description: Trace ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Backend operation trace
schema:
additionalProperties: true
type: object
"404":
description: Trace not found
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Get one backend operation trace
tags:
- monitoring
/api/backend-traces/clear:
post:
description: Removes all captured backend operation traces from the buffer
@@ -3628,8 +3669,23 @@ paths:
- router
/api/traces:
get:
description: Returns captured API exchange traces (request/response pairs) in
reverse chronological order
description: Returns a bounded, newest-first page of captured API exchange traces.
Request and response bodies plus headers are omitted unless full=true; fetch
them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count,
X-Trace-Offset and X-Trace-Limit headers.
parameters:
- description: Maximum entries to return (default 50, max 1000, 0 for all)
in: query
name: limit
type: integer
- description: Number of entries to skip (default 0)
in: query
name: offset
type: integer
- description: Include request/response bodies and headers (default false)
in: query
name: full
type: boolean
produces:
- application/json
responses:
@@ -3641,6 +3697,31 @@ paths:
summary: List API request/response traces
tags:
- monitoring
/api/traces/{id}:
get:
description: Returns a single captured API exchange, including the request and
response bodies omitted from the list response
parameters:
- description: Trace ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Traced API exchange
schema:
additionalProperties: true
type: object
"404":
description: Trace not found
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Get one API trace
tags:
- monitoring
/api/traces/clear:
post:
description: Removes all captured API request/response traces from the buffer