From ff299df453ec26ba06de681bbf885c3daf1958e0 Mon Sep 17 00:00:00 2001 From: "mudler's LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:51:25 +0200 Subject: [PATCH] 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 Co-authored-by: Ettore Di Giacinto --- core/cli/run.go | 4 + core/config/application_config.go | 21 ++- core/http/app.go | 34 +++- core/http/endpoints/localai/traces.go | 131 +++++++++++++++- core/http/endpoints/localai/traces_test.go | 134 ++++++++++++++++ core/http/middleware/compress.go | 113 ++++++++++++++ core/http/middleware/compress_test.go | 116 ++++++++++++++ core/http/middleware/trace.go | 64 ++++++++ core/http/middleware/trace_pagination_test.go | 145 +++++++++++++++++ core/http/react-ui/e2e/traces-audio.spec.js | 4 +- core/http/react-ui/e2e/traces-errors.spec.js | 8 +- .../http/react-ui/e2e/traces-metadata.spec.js | 4 +- .../react-ui/e2e/traces-pagination.spec.js | 82 ++++++++++ core/http/react-ui/src/pages/Traces.jsx | 74 +++++++-- core/http/react-ui/src/utils/api.js | 23 ++- core/http/react-ui/src/utils/config.js | 2 + core/http/routes/localai.go | 4 + core/http/static_cache_test.go | 146 ++++++++++++++++++ core/trace/backend_trace.go | 52 +++++++ docs/content/features/authentication.md | 4 +- docs/content/reference/cli-reference.md | 2 + swagger/docs.go | 116 +++++++++++++- swagger/swagger.json | 116 +++++++++++++- swagger/swagger.yaml | 89 ++++++++++- 24 files changed, 1439 insertions(+), 49 deletions(-) create mode 100644 core/http/middleware/compress.go create mode 100644 core/http/middleware/compress_test.go create mode 100644 core/http/middleware/trace_pagination_test.go create mode 100644 core/http/react-ui/e2e/traces-pagination.spec.js create mode 100644 core/http/static_cache_test.go diff --git a/core/cli/run.go b/core/cli/run.go index 57689e792..77a9f23e6 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -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), diff --git a/core/config/application_config.go b/core/config/application_config.go index 194a9183a..7ea81b2b4 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -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 diff --git a/core/http/app.go b/core/http/app.go index 4d969b8fd..bee47013b 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -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 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()) diff --git a/core/http/endpoints/localai/traces.go b/core/http/endpoints/localai/traces.go index 20d0875f7..7e9f1216f 100644 --- a/core/http/endpoints/localai/traces.go +++ b/core/http/endpoints/localai/traces.go @@ -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) } } diff --git a/core/http/endpoints/localai/traces_test.go b/core/http/endpoints/localai/traces_test.go index b8474b784..040307fd5 100644 --- a/core/http/endpoints/localai/traces_test.go +++ b/core/http/endpoints/localai/traces_test.go @@ -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"])) + }) }) diff --git a/core/http/middleware/compress.go b/core/http/middleware/compress.go new file mode 100644 index 000000000..7fdd59643 --- /dev/null +++ b/core/http/middleware/compress.go @@ -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, + }) +} diff --git a/core/http/middleware/compress_test.go b/core/http/middleware/compress_test.go new file mode 100644 index 000000000..88ac98ed3 --- /dev/null +++ b/core/http/middleware/compress_test.go @@ -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)) + }) +}) diff --git a/core/http/middleware/trace.go b/core/http/middleware/trace.go index 92baf18a4..77180677a 100644 --- a/core/http/middleware/trace.go +++ b/core/http/middleware/trace.go @@ -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() diff --git a/core/http/middleware/trace_pagination_test.go b/core/http/middleware/trace_pagination_test.go new file mode 100644 index 000000000..24912145e --- /dev/null +++ b/core/http/middleware/trace_pagination_test.go @@ -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)) + }) +}) diff --git a/core/http/react-ui/e2e/traces-audio.spec.js b/core/http/react-ui/e2e/traces-audio.spec.js index 567fd56c2..ba270374c 100644 --- a/core/http/react-ui/e2e/traces-audio.spec.js +++ b/core/http/react-ui/e2e/traces-audio.spec.js @@ -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') diff --git a/core/http/react-ui/e2e/traces-errors.spec.js b/core/http/react-ui/e2e/traces-errors.spec.js index 18c82edf3..e80c97865 100644 --- a/core/http/react-ui/e2e/traces-errors.spec.js +++ b/core/http/react-ui/e2e/traces-errors.spec.js @@ -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([ diff --git a/core/http/react-ui/e2e/traces-metadata.spec.js b/core/http/react-ui/e2e/traces-metadata.spec.js index 9e8fdbe70..679add382 100644 --- a/core/http/react-ui/e2e/traces-metadata.spec.js +++ b/core/http/react-ui/e2e/traces-metadata.spec.js @@ -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') diff --git a/core/http/react-ui/e2e/traces-pagination.spec.js b/core/http/react-ui/e2e/traces-pagination.spec.js new file mode 100644 index 000000000..c6d7b89a1 --- /dev/null +++ b/core/http/react-ui/e2e/traces-pagination.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/src/pages/Traces.jsx b/core/http/react-ui/src/pages/Traces.jsx index 92c39c7cd..aff887d4a 100644 --- a/core/http/react-ui/src/pages/Traces.jsx +++ b/core/http/react-ui/src/pages/Traces.jsx @@ -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() { {sortedTraces.map((trace, i) => ( - setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}> + toggleRow(i, trace)} style={{ cursor: 'pointer' }}> {trace.request?.method || '-'} {trace.request?.path || '-'} @@ -658,7 +700,7 @@ export default function Traces() { {expandedRow === i && ( - + )} @@ -682,7 +724,7 @@ export default function Traces() { {sortedTraces.map((trace, i) => ( - setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}> + toggleRow(i, trace)} style={{ cursor: 'pointer' }}> {trace.type || '-'} {formatDateTime(trace.timestamp)} @@ -700,7 +742,7 @@ export default function Traces() { {expandedRow === i && ( - + )} diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js index e2b76ba32..8a5de08e0 100644 --- a/core/http/react-ui/src/utils/api.js +++ b/core/http/react-ui/src/utils/api.js @@ -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, {}), } diff --git a/core/http/react-ui/src/utils/config.js b/core/http/react-ui/src/utils/config.js index 3ddf19eb7..ed4776cb8 100644 --- a/core/http/react-ui/src/utils/config.js +++ b/core/http/react-ui/src/utils/config.js @@ -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 diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go index d0e44f8e1..4a83e9009 100644 --- a/core/http/routes/localai.go +++ b/core/http/routes/localai.go @@ -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 { diff --git a/core/http/static_cache_test.go b/core/http/static_cache_test.go new file mode 100644 index 000000000..75c124d2b --- /dev/null +++ b/core/http/static_cache_test.go @@ -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] +} diff --git a/core/trace/backend_trace.go b/core/trace/backend_trace.go index 8d8ef6d10..051b471bf 100644 --- a/core/trace/backend_trace.go +++ b/core/trace/backend_trace.go @@ -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 { diff --git a/docs/content/features/authentication.md b/docs/content/features/authentication.md index 704d97dff..39530d28c 100644 --- a/docs/content/features/authentication.md +++ b/docs/content/features/authentication.md @@ -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` diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md index 67e413072..d0b82ed6c 100644 --- a/docs/content/reference/cli-reference.md +++ b/docs/content/reference/cli-reference.md @@ -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` | diff --git a/swagger/docs.go b/swagger/docs.go index d557cef3c..5dd3705b9 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -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.", diff --git a/swagger/swagger.json b/swagger/swagger.json index 4d074a148..d5729c847 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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.", diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index e28632183..b22cef3c3 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -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