Skip to content

Commit ff299df

Browse files
localai-botmudler
andauthored
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>
1 parent 8eb8376 commit ff299df

24 files changed

Lines changed: 1439 additions & 49 deletions

core/cli/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ type RunCMD struct {
6969
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
7070
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
7171
DisableCSRF bool `env:"LOCALAI_DISABLE_CSRF" help:"Disable CSRF middleware (enabled by default)" group:"api"`
72+
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"`
73+
HTTPCompressionMinLength int `env:"LOCALAI_HTTP_COMPRESSION_MIN_LENGTH" default:"1024" help:"Minimum response size in bytes before gzip compression is applied" group:"api"`
7274
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
7375
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"`
7476
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 {
279281
config.WithCors(r.CORS),
280282
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
281283
config.WithDisableCSRF(r.DisableCSRF),
284+
config.WithDisableHTTPCompression(r.DisableHTTPCompression),
285+
config.WithHTTPCompressionMinLength(r.HTTPCompressionMinLength),
282286
config.WithThreads(r.Threads),
283287
config.WithUploadLimitMB(r.UploadLimit),
284288
config.WithApiKeys(r.APIKeys),

core/config/application_config.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ type ApplicationConfig struct {
5050
DynamicConfigsDirPollInterval time.Duration
5151
CORS bool
5252
DisableCSRF bool
53-
PreloadJSONModels string
53+
// DisableHTTPCompression turns off the gzip response middleware. Gzip is
54+
// on by default because the React UI bundle and the admin JSON APIs are
55+
// text-heavy; turn it off only when a fronting proxy already compresses.
56+
DisableHTTPCompression bool
57+
// HTTPCompressionMinLength is the response-size floor (bytes) below which
58+
// gzip is skipped. 0 keeps middleware.DefaultCompressionMinLength.
59+
HTTPCompressionMinLength int
60+
PreloadJSONModels string
5461
PreloadModelsFromPath string
5562
CORSAllowOrigins string
5663
ApiKeys []string
@@ -382,6 +389,18 @@ func WithDisableCSRF(b bool) AppOption {
382389
}
383390
}
384391

392+
func WithDisableHTTPCompression(b bool) AppOption {
393+
return func(o *ApplicationConfig) {
394+
o.DisableHTTPCompression = b
395+
}
396+
}
397+
398+
func WithHTTPCompressionMinLength(n int) AppOption {
399+
return func(o *ApplicationConfig) {
400+
o.HTTPCompressionMinLength = n
401+
}
402+
}
403+
385404
func WithP2PToken(s string) AppOption {
386405
return func(o *ApplicationConfig) {
387406
o.P2PToken = s

core/http/app.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ var reactUI embed.FS
4949

5050
var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/readyz"}
5151

52+
// immutableAssetCacheControl is the Cache-Control served for content-hashed
53+
// build output. The filename changes whenever the content does, so a one-year
54+
// TTL plus `immutable` is safe and removes both the re-download and the
55+
// conditional revalidation round-trip.
56+
const immutableAssetCacheControl = "public, max-age=31536000, immutable"
57+
5258
// applyModelLoadCooldown maps a ModelLoadCooldownError anywhere in err's chain
5359
// to HTTP 503 with a Retry-After header (whole seconds, floor 1), so a client
5460
// 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) {
202208
// errors — picks them up.
203209
e.Use(httpMiddleware.SecurityHeaders())
204210

211+
// Gzip responses. Registered before the tracing and handler middlewares so
212+
// the response writer it installs sits underneath them: the trace buffer
213+
// keeps capturing plaintext while the wire carries the compressed bytes.
214+
if !application.ApplicationConfig().DisableHTTPCompression {
215+
e.Use(httpMiddleware.Compression(application.ApplicationConfig().HTTPCompressionMinLength))
216+
}
217+
205218
// Custom logger middleware using xlog
206219
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
207220
return func(c echo.Context) error {
@@ -512,6 +525,10 @@ func API(application *application.Application) (*echo.Echo, error) {
512525
if err != nil {
513526
return c.String(http.StatusNotFound, "React UI not built")
514527
}
528+
// index.html names the content-hashed bundles, so it must never
529+
// be cached: a stale copy pins the browser to the previous
530+
// deploy's assets.
531+
c.Response().Header().Set("Cache-Control", "no-cache")
515532
// Inject <base href> for reverse-proxy support; baseURL comes
516533
// from attacker-controllable Host / X-Forwarded-Host headers.
517534
baseURL := httpMiddleware.BaseURL(c)
@@ -573,8 +590,10 @@ func API(application *application.Application) (*echo.Echo, error) {
573590
})
574591

575592
// Serve React static assets (JS, CSS, etc.) and i18n locale JSONs
576-
// from the embedded React build.
577-
serveReactSubdir := func(subdir string) echo.HandlerFunc {
593+
// from the embedded React build. cacheControl is stamped on every
594+
// hit so the browser can reuse the bytes instead of re-fetching
595+
// ~1.8 MB of bundle on each navigation.
596+
serveReactSubdir := func(subdir, cacheControl string) echo.HandlerFunc {
578597
return func(c echo.Context) error {
579598
p := subdir + "/" + c.Param("*")
580599
f, err := reactFS.Open(p)
@@ -586,14 +605,21 @@ func API(application *application.Application) (*echo.Echo, error) {
586605
if contentType == "" {
587606
contentType = echo.MIMEOctetStream
588607
}
608+
if cacheControl != "" {
609+
c.Response().Header().Set("Cache-Control", cacheControl)
610+
}
589611
return c.Stream(http.StatusOK, contentType, f)
590612
}
591613
}
592614
return echo.NewHTTPError(http.StatusNotFound)
593615
}
594616
}
595-
e.GET("/assets/*", serveReactSubdir("assets"))
596-
e.GET("/locales/*", serveReactSubdir("locales"))
617+
// Vite content-hashes everything under /assets (Manage-DrwQK63f.js),
618+
// so a given URL can never change content: cache it for a year and
619+
// skip revalidation entirely. Locale JSONs keep stable names, so
620+
// they only get a short TTL.
621+
e.GET("/assets/*", serveReactSubdir("assets", immutableAssetCacheControl))
622+
e.GET("/locales/*", serveReactSubdir("locales", "public, max-age=300"))
597623
}
598624
}
599625
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

core/http/endpoints/localai/traces.go

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,106 @@
11
package localai
22

33
import (
4+
"net/http"
5+
"strconv"
6+
47
"github.com/labstack/echo/v4"
58
"github.com/mudler/LocalAI/core/http/middleware"
9+
"github.com/mudler/LocalAI/core/schema"
610
"github.com/mudler/LocalAI/core/trace"
711
)
812

9-
// GetAPITracesEndpoint returns all API request/response traces
13+
// DefaultTraceListLimit bounds the trace list responses. The ring buffer holds
14+
// up to LOCALAI_TRACING_MAX_ITEMS entries (1024 in a typical deployment) and
15+
// each one can embed a full request/response payload, so returning the whole
16+
// buffer made /api/traces a multi-megabyte response that the admin UI
17+
// re-fetched on every poll. Callers that genuinely want everything can pass
18+
// limit=0.
19+
const DefaultTraceListLimit = 50
20+
21+
// MaxTraceListLimit caps an explicit limit so a client cannot ask for an
22+
// unbounded page by accident.
23+
const MaxTraceListLimit = 1000
24+
25+
// tracePageParams reads the limit/offset/full query parameters. Invalid values
26+
// fall back to the bounded defaults rather than erroring, so existing clients
27+
// keep working.
28+
func tracePageParams(c echo.Context) (offset, limit int, full bool) {
29+
limit = DefaultTraceListLimit
30+
if raw := c.QueryParam("limit"); raw != "" {
31+
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
32+
limit = n
33+
}
34+
}
35+
if limit > MaxTraceListLimit {
36+
limit = MaxTraceListLimit
37+
}
38+
if raw := c.QueryParam("offset"); raw != "" {
39+
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
40+
offset = n
41+
}
42+
}
43+
if raw := c.QueryParam("full"); raw != "" {
44+
full, _ = strconv.ParseBool(raw)
45+
}
46+
return offset, limit, full
47+
}
48+
49+
// setTracePageHeaders publishes the paging metadata out-of-band so the
50+
// response body stays a plain JSON array for existing consumers.
51+
func setTracePageHeaders(c echo.Context, total, offset, limit int) {
52+
h := c.Response().Header()
53+
h.Set("X-Total-Count", strconv.Itoa(total))
54+
h.Set("X-Trace-Offset", strconv.Itoa(offset))
55+
h.Set("X-Trace-Limit", strconv.Itoa(limit))
56+
}
57+
58+
func traceNotFound(c echo.Context) error {
59+
return c.JSON(http.StatusNotFound, schema.ErrorResponse{
60+
Error: &schema.APIError{Message: "trace not found", Code: http.StatusNotFound},
61+
})
62+
}
63+
64+
// GetAPITracesEndpoint returns a bounded page of API request/response traces
1065
// @Summary List API request/response traces
11-
// @Description Returns captured API exchange traces (request/response pairs) in reverse chronological order
66+
// @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.
1267
// @Tags monitoring
1368
// @Produce json
69+
// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
70+
// @Param offset query int false "Number of entries to skip (default 0)"
71+
// @Param full query bool false "Include request/response bodies and headers (default false)"
1472
// @Success 200 {object} map[string]any "Traced API exchanges"
1573
// @Router /api/traces [get]
1674
func GetAPITracesEndpoint() echo.HandlerFunc {
1775
return func(c echo.Context) error {
18-
return c.JSON(200, middleware.GetTraces())
76+
offset, limit, full := tracePageParams(c)
77+
page, total := middleware.GetTracesPage(offset, limit)
78+
if !full {
79+
for i := range page {
80+
page[i] = middleware.SummarizeExchange(page[i])
81+
}
82+
}
83+
setTracePageHeaders(c, total, offset, limit)
84+
return c.JSON(http.StatusOK, page)
85+
}
86+
}
87+
88+
// GetAPITraceEndpoint returns a single API trace with its full payload
89+
// @Summary Get one API trace
90+
// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response
91+
// @Tags monitoring
92+
// @Produce json
93+
// @Param id path string true "Trace ID"
94+
// @Success 200 {object} map[string]any "Traced API exchange"
95+
// @Failure 404 {object} schema.ErrorResponse "Trace not found"
96+
// @Router /api/traces/{id} [get]
97+
func GetAPITraceEndpoint() echo.HandlerFunc {
98+
return func(c echo.Context) error {
99+
exchange, ok := middleware.GetTrace(c.Param("id"))
100+
if !ok {
101+
return traceNotFound(c)
102+
}
103+
return c.JSON(http.StatusOK, exchange)
19104
}
20105
}
21106

@@ -28,20 +113,50 @@ func GetAPITracesEndpoint() echo.HandlerFunc {
28113
func ClearAPITracesEndpoint() echo.HandlerFunc {
29114
return func(c echo.Context) error {
30115
middleware.ClearTraces()
31-
return c.NoContent(204)
116+
return c.NoContent(http.StatusNoContent)
32117
}
33118
}
34119

35-
// GetBackendTracesEndpoint returns all backend operation traces
120+
// GetBackendTracesEndpoint returns a bounded page of backend operation traces
36121
// @Summary List backend operation traces
37-
// @Description Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order
122+
// @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.
38123
// @Tags monitoring
39124
// @Produce json
125+
// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
126+
// @Param offset query int false "Number of entries to skip (default 0)"
127+
// @Param full query bool false "Include the body and data payloads (default false)"
40128
// @Success 200 {object} map[string]any "Backend operation traces"
41129
// @Router /api/backend-traces [get]
42130
func GetBackendTracesEndpoint() echo.HandlerFunc {
43131
return func(c echo.Context) error {
44-
return c.JSON(200, trace.GetBackendTraces())
132+
offset, limit, full := tracePageParams(c)
133+
page, total := trace.GetBackendTracesPage(offset, limit)
134+
if !full {
135+
for i := range page {
136+
page[i] = trace.SummarizeBackendTrace(page[i])
137+
}
138+
}
139+
setTracePageHeaders(c, total, offset, limit)
140+
return c.JSON(http.StatusOK, page)
141+
}
142+
}
143+
144+
// GetBackendTraceEndpoint returns a single backend trace with its full payload
145+
// @Summary Get one backend operation trace
146+
// @Description Returns a single captured backend trace, including the body and data payloads omitted from the list response
147+
// @Tags monitoring
148+
// @Produce json
149+
// @Param id path string true "Trace ID"
150+
// @Success 200 {object} map[string]any "Backend operation trace"
151+
// @Failure 404 {object} schema.ErrorResponse "Trace not found"
152+
// @Router /api/backend-traces/{id} [get]
153+
func GetBackendTraceEndpoint() echo.HandlerFunc {
154+
return func(c echo.Context) error {
155+
t, ok := trace.GetBackendTrace(c.Param("id"))
156+
if !ok {
157+
return traceNotFound(c)
158+
}
159+
return c.JSON(http.StatusOK, t)
45160
}
46161
}
47162

@@ -54,6 +169,6 @@ func GetBackendTracesEndpoint() echo.HandlerFunc {
54169
func ClearBackendTracesEndpoint() echo.HandlerFunc {
55170
return func(c echo.Context) error {
56171
trace.ClearBackendTraces()
57-
return c.NoContent(204)
172+
return c.NoContent(http.StatusNoContent)
58173
}
59174
}

0 commit comments

Comments
 (0)