Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Entries before v0.4.0 were reconstructed from git history.

- The resolved upstream and where it came from (flag, `LK_UPSTREAM`, or default) are logged at startup. Nothing previously reported the upstream, so a misconfiguration gave no signal at all.
- The `/health` 503 body names the upstream it could not reach.
- One access line per request, tagged with the client protocol (`[openai]`, `[gemini]`, `[anthropic]`) and logging method, path, status, and latency. Headers and bodies are never logged, and successful `/health` probes are skipped. Set `LK_LOG=off` to silence it.

### Fixed

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ services:
| `LK_MLOCK` | 0 (off) | Lock model in RAM (`1` to enable) |


## Logging

localaik logs one line per request: method, path, status, and latency.

```
localaik [openai] POST /v1/chat/completions 200 412.183ms
localaik [gemini] POST /v1beta/models/gemma:generateContent 200 1.204s
localaik [anthropic] POST /v1/messages 502 Bad Gateway 8.41ms
```

Set `LK_LOG=off` to silence per-request logging; the startup lines still print. Only `off` disables it, so `LK_LOG=0` and `LK_LOG=false` leave logging on. Headers and request bodies are never logged, so credentials and prompts stay out of the log. Successful `/health` probes are skipped so the log stays focused on real traffic.


## Bring your own model server (`:proxy`)

If you already run llama.cpp, vLLM, or anything else that speaks the OpenAI
Expand Down
14 changes: 13 additions & 1 deletion cmd/localaik/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"net/http"
"os"
"strings"
"time"

"github.com/harshaneel/localaik/internal/pdf"
Expand Down Expand Up @@ -38,6 +39,12 @@ func upstreamRequiredButUnset(source, requireEnv string) bool {
return source == "default" && requireEnv != ""
}

// requestLoggingEnabled reports whether per-request logging is on. Only "off"
// disables it; every other value, including "0" and "false", leaves it on.
func requestLoggingEnabled(env string) bool {
return !strings.EqualFold(env, "off")
}

func flagWasSet(name string) bool {
set := false
flag.Visit(func(f *flag.Flag) {
Expand Down Expand Up @@ -77,9 +84,14 @@ func main() {
log.Fatalf("localaik: %v", err)
}

var reqLogger *log.Logger
if requestLoggingEnabled(os.Getenv("LK_LOG")) {
reqLogger = log.Default()
}

httpServer := &http.Server{
Addr: ":" + *port,
Handler: handler,
Handler: server.WithRequestLog(handler, reqLogger),
ReadHeaderTimeout: 10 * time.Second,
}

Expand Down
20 changes: 20 additions & 0 deletions cmd/localaik/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ func TestUpstreamRequiredButUnset(t *testing.T) {
}
}

func TestRequestLoggingEnabled(t *testing.T) {
tests := []struct {
env string
want bool
}{
{"", true},
{"off", false},
{"OFF", false},
{"Off", false},
{"on", true},
{"0", true},
{"false", true},
}
for _, tc := range tests {
if got := requestLoggingEnabled(tc.env); got != tc.want {
t.Errorf("requestLoggingEnabled(%q) = %v, want %v", tc.env, got, tc.want)
}
}
}

// The startup warning must be driven by the same predicate the transport uses;
// server.ValidUpstreamAuthHeader owns the table of cases.
func TestStartupWarningUsesTheServerPredicate(t *testing.T) {
Expand Down
109 changes: 109 additions & 0 deletions internal/server/logging.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package server

import (
"log"
"net/http"
"strconv"
"strings"
"time"
)

// WithRequestLog wraps next so each request is logged as one access line:
// method, path, status and latency. Headers and bodies are never logged, so no
// credential or prompt content can leak through here. A nil logger disables
// logging and returns next unchanged.
func WithRequestLog(next http.Handler, logger *log.Logger) http.Handler {
if logger == nil {
return next
}
return &requestLogger{next: next, logger: logger}
}

type requestLogger struct {
next http.Handler
logger *log.Logger
}

func (l *requestLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}

// Deferred so a panicking handler still leaves an access line.
defer func() {
dur := time.Since(start).Round(time.Microsecond)

// Health probes hit this every few seconds, so log them only when failing.
if r.URL.Path == "/health" && rec.status < http.StatusBadRequest {
return
}

method, path := sanitizeLogField(r.Method), sanitizeLogField(r.URL.Path)
status := strconv.Itoa(rec.status)
if rec.status >= http.StatusBadRequest {
status += " " + http.StatusText(rec.status)
}
tag := "[" + protocolLabel(r.URL.Path) + "]"
l.logger.Printf("localaik %-11s %s %s %s %s", tag, method, path, status, dur)
}()

l.next.ServeHTTP(rec, r)
}

// protocolLabel names the client API a request path belongs to, matching the
// routing in ServeHTTP, so the access line shows which of the three protocols
// was used.
func protocolLabel(path string) string {
switch {
case strings.HasPrefix(path, "/v1beta/"):
return "gemini"
case path == "/v1/messages" || strings.HasPrefix(path, "/v1/messages/"):
return "anthropic"
case strings.HasPrefix(path, "/v1/"):
return "openai"
default:
return "-"
}
}

// sanitizeLogField quotes a value that carries control characters, so a decoded
// request path cannot forge extra log lines or inject terminal escapes.
func sanitizeLogField(s string) string {
for _, c := range s {
if c < 0x20 || c == 0x7f {
return strconv.Quote(s)
}
}
return s
}

// statusRecorder captures the response status while preserving http.Flusher, so
// streaming responses still flush through the middleware.
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}

// WriteHeader records only the first status, matching net/http, so the logged
// status is the one the client actually received.
func (s *statusRecorder) WriteHeader(code int) {
if s.wroteHeader {
return
}
s.wroteHeader = true
s.status = code
s.ResponseWriter.WriteHeader(code)
}

// Write locks in the implicit 200, matching net/http, so a later WriteHeader
// cannot make the log disagree with the status the client received.
func (s *statusRecorder) Write(b []byte) (int, error) {
s.wroteHeader = true
return s.ResponseWriter.Write(b)
}

func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
Loading
Loading