Skip to content

Commit 0f133f5

Browse files
committed
Add /logs endpoint to monitor upstream processes
- outputs last 10KB of logs from upstream processes - supports streaming
1 parent 1510b3f commit 0f133f5

File tree

3 files changed

+155
-5
lines changed

3 files changed

+155
-5
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ models:
5959
* _Note: Windows currently untested._
6060
1. Run the binary with `llama-swap --config path/to/config.yaml`
6161

62+
## Monitoring Logs
63+
64+
The `/logs` endpoint is available to monitor what llama-swap is doing. It will send the last 10KB of logs. Useful for monitoring the output of llama-server. It also supports streaming of logs.
65+
66+
Usage:
67+
68+
```
69+
# basic, sends up to the last 10KB of logs
70+
curl http://host/logs'
71+
72+
# add `stream` to stream new logs as they come in
73+
curl -Ns 'http://host/logs?stream'
74+
75+
# add `skip` to skip history (only useful if used with stream)
76+
curl -Ns 'http://host/logs?stream&skip'
77+
78+
# will output nothing :)
79+
curl -Ns 'http://host/logs?skip'
80+
```
81+
6282
## Systemd Unit Files
6383

6484
Use this unit file to start llama-swap on boot. This is only tested on Ubuntu.

proxy/logMonitor.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package proxy
2+
3+
import (
4+
"container/ring"
5+
"os"
6+
"sync"
7+
)
8+
9+
type LogMonitor struct {
10+
clients map[chan string]bool
11+
mu sync.RWMutex
12+
buffer *ring.Ring
13+
bufferMu sync.RWMutex
14+
}
15+
16+
func NewLogMonitor() *LogMonitor {
17+
return &LogMonitor{
18+
clients: make(map[chan string]bool),
19+
buffer: ring.New(10 * 1024), // keep 10KB of buffered logs
20+
}
21+
}
22+
23+
func (w *LogMonitor) Write(p []byte) (n int, err error) {
24+
n, err = os.Stdout.Write(p)
25+
if err != nil {
26+
return n, err
27+
}
28+
29+
content := string(p)
30+
31+
w.bufferMu.Lock()
32+
w.buffer.Value = content
33+
w.buffer = w.buffer.Next()
34+
w.bufferMu.Unlock()
35+
36+
w.Broadcast(content)
37+
return n, nil
38+
}
39+
40+
func (w *LogMonitor) getHistory() string {
41+
w.bufferMu.RLock()
42+
defer w.bufferMu.RUnlock()
43+
44+
var history string
45+
w.buffer.Do(func(p interface{}) {
46+
if p != nil {
47+
if content, ok := p.(string); ok {
48+
history += content
49+
}
50+
}
51+
})
52+
return history
53+
}
54+
55+
func (w *LogMonitor) Subscribe() chan string {
56+
w.mu.Lock()
57+
defer w.mu.Unlock()
58+
59+
ch := make(chan string, 100)
60+
w.clients[ch] = true
61+
return ch
62+
}
63+
64+
func (w *LogMonitor) Unsubscribe(ch chan string) {
65+
w.mu.Lock()
66+
defer w.mu.Unlock()
67+
68+
delete(w.clients, ch)
69+
close(ch)
70+
}
71+
72+
func (w *LogMonitor) Broadcast(msg string) {
73+
w.mu.RLock()
74+
defer w.mu.RUnlock()
75+
76+
for client := range w.clients {
77+
select {
78+
case client <- msg:
79+
default:
80+
// If client buffer is full, skip
81+
}
82+
}
83+
}

proxy/manager.go

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"io"
99
"net/http"
1010
"net/url"
11-
"os"
1211
"os/exec"
1312
"strings"
1413
"sync"
@@ -22,10 +21,11 @@ type ProxyManager struct {
2221
config *Config
2322
currentCmd *exec.Cmd
2423
currentConfig ModelConfig
24+
logMonitor *LogMonitor
2525
}
2626

2727
func New(config *Config) *ProxyManager {
28-
return &ProxyManager{config: config}
28+
return &ProxyManager{config: config, logMonitor: NewLogMonitor()}
2929
}
3030

3131
func (pm *ProxyManager) HandleFunc(w http.ResponseWriter, r *http.Request) {
@@ -36,12 +36,55 @@ func (pm *ProxyManager) HandleFunc(w http.ResponseWriter, r *http.Request) {
3636
pm.proxyChatRequest(w, r)
3737
} else if r.URL.Path == "/v1/models" {
3838
pm.listModels(w, r)
39+
} else if r.URL.Path == "/logs" {
40+
pm.streamLogs(w, r)
3941
} else {
4042
pm.proxyRequest(w, r)
4143
}
4244
}
4345

44-
func (pm *ProxyManager) listModels(w http.ResponseWriter, r *http.Request) {
46+
func (pm *ProxyManager) streamLogs(w http.ResponseWriter, r *http.Request) {
47+
w.Header().Set("Content-Type", "text/plain")
48+
w.Header().Set("Transfer-Encoding", "chunked")
49+
w.Header().Set("X-Content-Type-Options", "nosniff")
50+
51+
ch := pm.logMonitor.Subscribe()
52+
defer pm.logMonitor.Unsubscribe(ch)
53+
54+
notify := r.Context().Done()
55+
flusher, ok := w.(http.Flusher)
56+
if !ok {
57+
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
58+
return
59+
}
60+
61+
skipHistory := r.URL.Query().Has("skip")
62+
if !skipHistory {
63+
// Send history first
64+
history := pm.logMonitor.getHistory()
65+
if history != "" {
66+
fmt.Fprint(w, history)
67+
flusher.Flush()
68+
}
69+
}
70+
71+
if !r.URL.Query().Has("stream") {
72+
return
73+
}
74+
75+
// Stream new logs
76+
for {
77+
select {
78+
case msg := <-ch:
79+
fmt.Fprint(w, msg)
80+
flusher.Flush()
81+
case <-notify:
82+
return
83+
}
84+
}
85+
}
86+
87+
func (pm *ProxyManager) listModels(w http.ResponseWriter, _ *http.Request) {
4588
data := []interface{}{}
4689
for id := range pm.config.Models {
4790
data = append(data, map[string]interface{}{
@@ -92,8 +135,12 @@ func (pm *ProxyManager) swapModel(requestedModel string) error {
92135
return fmt.Errorf("unable to get sanitized command: %v", err)
93136
}
94137
cmd := exec.Command(args[0], args[1:]...)
95-
cmd.Stdout = os.Stdout
96-
cmd.Stderr = os.Stderr
138+
139+
// logMonitor only writes to stdout
140+
// so the upstream's stderr will go to os.Stdout
141+
cmd.Stdout = pm.logMonitor
142+
cmd.Stderr = pm.logMonitor
143+
97144
cmd.Env = modelConfig.Env
98145

99146
err = cmd.Start()

0 commit comments

Comments
 (0)