Skip to content

Commit b267ead

Browse files
committed
feat(traces): Add traces UI
Signed-off-by: Richard Palethorpe <[email protected]>
1 parent cfef652 commit b267ead

File tree

10 files changed

+543
-107
lines changed

10 files changed

+543
-107
lines changed

core/cli/run.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ type RunCMD struct {
7979
MachineTag string `env:"LOCALAI_MACHINE_TAG,MACHINE_TAG" help:"Add Machine-Tag header to each response which is useful to track the machine in the P2P network" group:"api"`
8080
LoadToMemory []string `env:"LOCALAI_LOAD_TO_MEMORY,LOAD_TO_MEMORY" help:"A list of models to load into memory at startup" group:"models"`
8181
EnableTracing bool `env:"LOCALAI_ENABLE_TRACING,ENABLE_TRACING" help:"Enable API tracing" group:"api"`
82+
TracingMaxItems int `env:"LOCALAI_TRACING_MAX_ITEMS" default:"1024" help:"Maximum number of traces to keep" group:"api"`
8283
AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Number of days to keep agent job history (default: 30)" group:"api"`
8384

8485
Version bool
@@ -155,6 +156,11 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
155156
opts = append(opts, config.EnableTracing)
156157
}
157158

159+
if r.EnableTracing {
160+
opts = append(opts, config.EnableTracing)
161+
}
162+
opts = append(opts, config.WithTracingMaxItems(r.TracingMaxItems))
163+
158164
token := ""
159165
if r.Peer2Peer || r.Peer2PeerToken != "" {
160166
log.Info().Msg("P2P mode enabled")

core/config/application_config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type ApplicationConfig struct {
2020
F16 bool
2121
Debug bool
2222
EnableTracing bool
23+
TracingMaxItems int
2324
GeneratedContentDir string
2425

2526
UploadDir string
@@ -90,6 +91,7 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
9091
Context: context.Background(),
9192
UploadLimitMB: 15,
9293
Debug: true,
94+
TracingMaxItems: 1024,
9395
AgentJobRetentionDays: 30, // Default: 30 days
9496
PathWithoutAuth: []string{
9597
"/static/",
@@ -391,6 +393,12 @@ func WithDebug(debug bool) AppOption {
391393
}
392394
}
393395

396+
func WithTracingMaxItems(items int) AppOption {
397+
return func(o *ApplicationConfig) {
398+
o.TracingMaxItems = items
399+
}
400+
}
401+
394402
func WithGeneratedContentDir(generatedContentDir string) AppOption {
395403
return func(o *ApplicationConfig) {
396404
o.GeneratedContentDir = generatedContentDir
@@ -514,6 +522,8 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
514522
contextSize := o.ContextSize
515523
f16 := o.F16
516524
debug := o.Debug
525+
tracingMaxItems := o.TracingMaxItems
526+
enableTracing := o.EnableTracing
517527
cors := o.CORS
518528
csrf := o.CSRF
519529
corsAllowOrigins := o.CORSAllowOrigins
@@ -561,6 +571,8 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
561571
ContextSize: &contextSize,
562572
F16: &f16,
563573
Debug: &debug,
574+
TracingMaxItems: &tracingMaxItems,
575+
EnableTracing: &enableTracing,
564576
CORS: &cors,
565577
CSRF: &csrf,
566578
CORSAllowOrigins: &corsAllowOrigins,
@@ -661,6 +673,12 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
661673
if settings.Debug != nil {
662674
o.Debug = *settings.Debug
663675
}
676+
if settings.EnableTracing != nil {
677+
o.EnableTracing = *settings.EnableTracing
678+
}
679+
if settings.TracingMaxItems != nil {
680+
o.TracingMaxItems = *settings.TracingMaxItems
681+
}
664682
if settings.CORS != nil {
665683
o.CORS = *settings.CORS
666684
}

core/config/runtime_settings.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ type RuntimeSettings struct {
2727
MemoryReclaimerThreshold *float64 `json:"memory_reclaimer_threshold,omitempty"` // Threshold 0.0-1.0 (e.g., 0.95 = 95%)
2828

2929
// Performance settings
30-
Threads *int `json:"threads,omitempty"`
31-
ContextSize *int `json:"context_size,omitempty"`
32-
F16 *bool `json:"f16,omitempty"`
33-
Debug *bool `json:"debug,omitempty"`
30+
Threads *int `json:"threads,omitempty"`
31+
ContextSize *int `json:"context_size,omitempty"`
32+
F16 *bool `json:"f16,omitempty"`
33+
Debug *bool `json:"debug,omitempty"`
34+
EnableTracing *bool `json:"enable_tracing,omitempty"`
35+
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
3436

3537
// Security/CORS settings
3638
CORS *bool `json:"cors,omitempty"`

core/http/middleware/trace.go

Lines changed: 62 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,40 @@ package middleware
22

33
import (
44
"bytes"
5+
"github.com/emirpasic/gods/v2/queues/circularbuffer"
56
"io"
67
"net/http"
8+
"sort"
79
"sync"
10+
"time"
811

912
"github.com/labstack/echo/v4"
1013
"github.com/mudler/LocalAI/core/application"
1114
"github.com/rs/zerolog/log"
1215
)
1316

17+
type APIExchangeRequest struct {
18+
Method string `json:"method"`
19+
Path string `json:"path"`
20+
Headers *http.Header `json:"headers"`
21+
Body *[]byte `json:"body"`
22+
}
23+
24+
type APIExchangeResponse struct {
25+
Status int `json:"status"`
26+
Headers *http.Header `json:"headers"`
27+
Body *[]byte `json:"body"`
28+
}
29+
1430
type APIExchange struct {
15-
Request struct {
16-
Method string
17-
Path string
18-
Headers http.Header
19-
Body []byte
20-
}
21-
Response struct {
22-
Status int
23-
Headers http.Header
24-
Body []byte
25-
}
31+
Timestamp time.Time `json:"timestamp"`
32+
Request APIExchangeRequest `json:"request"`
33+
Response APIExchangeResponse `json:"response"`
2634
}
2735

28-
var apiLogs []APIExchange
36+
var traceBuffer *circularbuffer.Queue[APIExchange]
2937
var mu sync.Mutex
30-
var logChan = make(chan APIExchange, 100) // Buffered channel for serialization
31-
32-
func init() {
33-
go func() {
34-
for exchange := range logChan {
35-
mu.Lock()
36-
apiLogs = append(apiLogs, exchange)
37-
mu.Unlock()
38-
log.Debug().Msgf("Logged exchange: %s %s - Status: %d", exchange.Request.Method, exchange.Request.Path, exchange.Response.Status)
39-
}
40-
}()
41-
}
38+
var logChan = make(chan APIExchange, 100)
4239

4340
type bodyWriter struct {
4441
http.ResponseWriter
@@ -58,13 +55,24 @@ func (w *bodyWriter) Flush() {
5855

5956
// TraceMiddleware intercepts and logs JSON API requests and responses
6057
func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
58+
if app.ApplicationConfig().EnableTracing && traceBuffer == nil {
59+
traceBuffer = circularbuffer.New[APIExchange](app.ApplicationConfig().TracingMaxItems)
60+
61+
go func() {
62+
for exchange := range logChan {
63+
mu.Lock()
64+
traceBuffer.Enqueue(exchange)
65+
mu.Unlock()
66+
}
67+
}()
68+
}
69+
6170
return func(next echo.HandlerFunc) echo.HandlerFunc {
6271
return func(c echo.Context) error {
6372
if !app.ApplicationConfig().EnableTracing {
6473
return next(c)
6574
}
6675

67-
// Only log if Content-Type is application/json
6876
if c.Request().Header.Get("Content-Type") != "application/json" {
6977
return next(c)
7078
}
@@ -78,6 +86,8 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
7886
// Restore the body for downstream handlers
7987
c.Request().Body = io.NopCloser(bytes.NewBuffer(body))
8088

89+
startTime := time.Now()
90+
8191
// Wrap response writer to capture body
8292
resBody := new(bytes.Buffer)
8393
mw := &bodyWriter{
@@ -93,51 +103,54 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
93103
}
94104

95105
// Create exchange log
106+
requestHeaders := c.Request().Header.Clone()
107+
requestBody := make([]byte, len(body))
108+
copy(requestBody, body)
109+
responseHeaders := c.Response().Header().Clone()
110+
responseBody := make([]byte, resBody.Len())
111+
copy(responseBody, resBody.Bytes())
96112
exchange := APIExchange{
97-
Request: struct {
98-
Method string
99-
Path string
100-
Headers http.Header
101-
Body []byte
102-
}{
113+
Timestamp: startTime,
114+
Request: APIExchangeRequest{
103115
Method: c.Request().Method,
104116
Path: c.Path(),
105-
Headers: c.Request().Header.Clone(),
106-
Body: body,
117+
Headers: &requestHeaders,
118+
Body: &requestBody,
107119
},
108-
Response: struct {
109-
Status int
110-
Headers http.Header
111-
Body []byte
112-
}{
120+
Response: APIExchangeResponse{
113121
Status: c.Response().Status,
114-
Headers: c.Response().Header().Clone(),
115-
Body: resBody.Bytes(),
122+
Headers: &responseHeaders,
123+
Body: &responseBody,
116124
},
117125
}
118126

119-
// Send to channel (non-blocking)
120127
select {
121128
case logChan <- exchange:
122129
default:
123-
log.Warn().Msg("API log channel full, dropping log")
130+
log.Warn().Msg("Trace channel full, dropping trace")
124131
}
125132

126133
return nil
127134
}
128135
}
129136
}
130137

131-
// GetAPILogs returns a copy of the logged API exchanges for display
132-
func GetAPILogs() []APIExchange {
138+
// GetTraces returns a copy of the logged API exchanges for display
139+
func GetTraces() []APIExchange {
133140
mu.Lock()
134-
defer mu.Unlock()
135-
return append([]APIExchange{}, apiLogs...)
141+
traces := traceBuffer.Values()
142+
mu.Unlock()
143+
144+
sort.Slice(traces, func(i, j int) bool {
145+
return traces[i].Timestamp.Before(traces[j].Timestamp)
146+
})
147+
148+
return traces
136149
}
137150

138-
// ClearAPILogs clears the in-memory logs
139-
func ClearAPILogs() {
151+
// ClearTraces clears the in-memory logs
152+
func ClearTraces() {
140153
mu.Lock()
141-
apiLogs = nil
154+
traceBuffer.Clear()
142155
mu.Unlock()
143156
}

core/http/routes/ui.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,4 +317,24 @@ func RegisterUIRoutes(app *echo.Echo,
317317
// Render index
318318
return c.Render(200, "views/tts", summary)
319319
})
320+
321+
// Traces UI
322+
app.GET("/traces", func(c echo.Context) error {
323+
summary := map[string]interface{}{
324+
"Title": "LocalAI - Traces",
325+
"BaseURL": middleware.BaseURL(c),
326+
"Version": internal.PrintableVersion(),
327+
}
328+
return c.Render(200, "views/traces", summary)
329+
})
330+
331+
app.GET("/api/traces", func(c echo.Context) error {
332+
return c.JSON(200, middleware.GetTraces())
333+
})
334+
335+
app.POST("/api/traces/clear", func(c echo.Context) error {
336+
middleware.ClearTraces()
337+
return c.NoContent(204)
338+
})
339+
320340
}

core/http/routes/ui_api.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -956,14 +956,14 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
956956
"error": "Tracing disabled",
957957
})
958958
}
959-
logs := middleware.GetAPILogs()
959+
traces := middleware.GetTraces()
960960
return c.JSON(200, map[string]interface{}{
961-
"logs": logs,
961+
"traces": traces,
962962
})
963963
})
964964

965965
app.POST("/api/traces/clear", func(c echo.Context) error {
966-
middleware.ClearAPILogs()
966+
middleware.ClearTraces()
967967
return c.JSON(200, map[string]interface{}{
968968
"message": "Traces cleared",
969969
})

0 commit comments

Comments
 (0)