All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add an optional exporter that writes one point per proxied request to the InfluxDB 3 write endpoint. Points are batched in memory and flushed by size or interval. The exporter is off until GATEWAY_INFLUX_URL is set.
259 lines
7.3 KiB
Go
259 lines
7.3 KiB
Go
package influx
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"git.govekar.net/LovroG05/localmodelroutex/internal/store"
|
|
)
|
|
|
|
func TestEncodeEscapesAndSorts(t *testing.T) {
|
|
p := Point{
|
|
Measurement: "my measure,ment",
|
|
Tags: map[string]string{"z": "last", "a": "has space", "empty": ""},
|
|
Fields: map[string]any{
|
|
"s": `quote " back \ newline` + "\nend",
|
|
"i": int(7),
|
|
"i64": int64(-3),
|
|
"f": 1.5,
|
|
"b": true,
|
|
"nan": 0.0 / zero(),
|
|
"bad": struct{}{},
|
|
},
|
|
Time: time.Unix(1, 2),
|
|
}
|
|
var b strings.Builder
|
|
if !p.encode(&b) {
|
|
t.Fatal("encode returned false")
|
|
}
|
|
want := `my\ measure\,ment,a=has\ space,z=last b=true,f=1.5,i=7i,i64=-3i,s="quote \" back \\ newline end" 1000000002` + "\n"
|
|
if b.String() != want {
|
|
t.Fatalf("got\n%s\nwant\n%s", b.String(), want)
|
|
}
|
|
}
|
|
|
|
func zero() float64 { return 0 }
|
|
|
|
func TestEncodeNoFields(t *testing.T) {
|
|
var b strings.Builder
|
|
if (Point{Measurement: "m", Fields: map[string]any{"x": struct{}{}}}).encode(&b) {
|
|
t.Fatal("expected false for a point without usable fields")
|
|
}
|
|
if b.Len() != 0 {
|
|
t.Fatalf("expected no output, got %q", b.String())
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsBadConfig(t *testing.T) {
|
|
cases := []Config{
|
|
{Database: "d", Measurement: "m"},
|
|
{URL: "http://x", Measurement: "m"},
|
|
{URL: "http://x", Database: "d"},
|
|
{URL: "not a url", Database: "d", Measurement: "m"},
|
|
{URL: "ftp://x", Database: "d", Measurement: "m"},
|
|
}
|
|
for i, c := range cases {
|
|
if _, err := New(c); err == nil {
|
|
t.Errorf("case %d: expected error for %+v", i, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewWriteURL(t *testing.T) {
|
|
e, err := New(Config{URL: "http://influx:8181/prefix/", Database: "lmr", Measurement: "m"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "http://influx:8181/prefix/api/v3/write_lp?db=lmr&precision=nanosecond"
|
|
if e.writeURL != want {
|
|
t.Fatalf("writeURL = %q, want %q", e.writeURL, want)
|
|
}
|
|
}
|
|
|
|
// captureServer records every write body and the auth header it saw.
|
|
type captureServer struct {
|
|
*httptest.Server
|
|
mu sync.Mutex
|
|
bodies []string
|
|
auth string
|
|
status atomic.Int32
|
|
got chan struct{}
|
|
}
|
|
|
|
func newCaptureServer(t *testing.T) *captureServer {
|
|
t.Helper()
|
|
cs := &captureServer{got: make(chan struct{}, 64)}
|
|
cs.status.Store(http.StatusNoContent)
|
|
cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v3/write_lp" {
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
}
|
|
b, _ := io.ReadAll(r.Body)
|
|
cs.mu.Lock()
|
|
cs.bodies = append(cs.bodies, string(b))
|
|
cs.auth = r.Header.Get("Authorization")
|
|
cs.mu.Unlock()
|
|
w.WriteHeader(int(cs.status.Load()))
|
|
cs.got <- struct{}{}
|
|
}))
|
|
t.Cleanup(cs.Close)
|
|
return cs
|
|
}
|
|
|
|
func (cs *captureServer) wait(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case <-cs.got:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("no write arrived")
|
|
}
|
|
}
|
|
|
|
func newTestExporter(t *testing.T, srv *captureServer, cfg Config) *Exporter {
|
|
t.Helper()
|
|
cfg.URL = srv.URL
|
|
cfg.Database = "lmr"
|
|
cfg.Measurement = "lmr_request"
|
|
cfg.Token = "secret"
|
|
e, err := New(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.now = func() time.Time { return time.Unix(100, 0) }
|
|
return e
|
|
}
|
|
|
|
func TestRecordWritesRequestPoint(t *testing.T) {
|
|
srv := newCaptureServer(t)
|
|
e := newTestExporter(t, srv, Config{FlushInterval: time.Hour, BatchSize: 1})
|
|
e.Start()
|
|
|
|
uid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
|
ttft := 42
|
|
e.Record(store.RequestLog{
|
|
UserID: &uid,
|
|
KeyPrefix: "lmr_abc",
|
|
ModelName: "openai/gpt-4o",
|
|
ProviderName: "openai",
|
|
StatusCode: 200,
|
|
Streamed: true,
|
|
PromptTokens: 10,
|
|
CompletionTokens: 5,
|
|
CachedTokens: 2,
|
|
TotalTokens: 15,
|
|
Cost: 0.00125,
|
|
LatencyMS: 321,
|
|
TTFTMs: &ttft,
|
|
RequestID: "req-1",
|
|
})
|
|
srv.wait(t)
|
|
if err := e.Close(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
if srv.auth != "Bearer secret" {
|
|
t.Fatalf("auth = %q", srv.auth)
|
|
}
|
|
want := `lmr_request,key_prefix=lmr_abc,model=openai/gpt-4o,provider=openai,status_code=200,streamed=true,user_id=11111111-1111-1111-1111-111111111111 ` +
|
|
`cached_tokens=2i,completion_tokens=5i,cost=0.00125,errors=0i,latency_ms=321i,prompt_tokens=10i,request_id="req-1",requests=1i,total_tokens=15i,ttft_ms=42i 100000000000` + "\n"
|
|
if len(srv.bodies) != 1 || srv.bodies[0] != want {
|
|
t.Fatalf("bodies = %q\nwant %q", srv.bodies, want)
|
|
}
|
|
}
|
|
|
|
func TestRecordMarksErrors(t *testing.T) {
|
|
srv := newCaptureServer(t)
|
|
e := newTestExporter(t, srv, Config{FlushInterval: time.Hour, BatchSize: 1})
|
|
long := strings.Repeat("x", maxErrorLen+10)
|
|
p := e.requestPoint(store.RequestLog{StatusCode: 502, Error: long})
|
|
if p.Fields["errors"] != 1 {
|
|
t.Fatalf("errors = %v", p.Fields["errors"])
|
|
}
|
|
if got := p.Fields["error"].(string); len(got) != maxErrorLen {
|
|
t.Fatalf("error length = %d", len(got))
|
|
}
|
|
p = e.requestPoint(store.RequestLog{StatusCode: 200, Error: "stream interrupted"})
|
|
if p.Fields["errors"] != 1 {
|
|
t.Fatal("a 200 with an error message must count as an error")
|
|
}
|
|
}
|
|
|
|
func TestBatchAndFlushOnClose(t *testing.T) {
|
|
srv := newCaptureServer(t)
|
|
e := newTestExporter(t, srv, Config{FlushInterval: time.Hour, BatchSize: 2})
|
|
e.Start()
|
|
for range 3 {
|
|
e.Record(store.RequestLog{StatusCode: 200})
|
|
}
|
|
srv.wait(t) // first batch of 2
|
|
if err := e.Close(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
if len(srv.bodies) != 2 {
|
|
t.Fatalf("expected 2 writes, got %d", len(srv.bodies))
|
|
}
|
|
if n := strings.Count(srv.bodies[0], "\n"); n != 2 {
|
|
t.Fatalf("first batch has %d lines, want 2", n)
|
|
}
|
|
if n := strings.Count(srv.bodies[1], "\n"); n != 1 {
|
|
t.Fatalf("second batch has %d lines, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestFlushOnInterval(t *testing.T) {
|
|
srv := newCaptureServer(t)
|
|
e := newTestExporter(t, srv, Config{FlushInterval: 20 * time.Millisecond, BatchSize: 100})
|
|
e.Start()
|
|
defer e.Close(context.Background()) //nolint:errcheck
|
|
e.Record(store.RequestLog{StatusCode: 200})
|
|
srv.wait(t)
|
|
}
|
|
|
|
func TestEnqueueDropsWhenFull(t *testing.T) {
|
|
srv := newCaptureServer(t)
|
|
e := newTestExporter(t, srv, Config{FlushInterval: time.Hour, BatchSize: 1, BufferSize: 1})
|
|
// Not started: nothing drains the queue.
|
|
e.Enqueue(Point{Measurement: "m", Fields: map[string]any{"v": 1}})
|
|
e.Enqueue(Point{Measurement: "m", Fields: map[string]any{"v": 1}})
|
|
if e.dropped.Load() != 1 {
|
|
t.Fatalf("dropped = %d, want 1", e.dropped.Load())
|
|
}
|
|
}
|
|
|
|
func TestWriteDoesNotRetryClientErrors(t *testing.T) {
|
|
srv := newCaptureServer(t)
|
|
srv.status.Store(http.StatusBadRequest)
|
|
e := newTestExporter(t, srv, Config{FlushInterval: time.Hour, BatchSize: 1})
|
|
e.write([]Point{{Measurement: "m", Fields: map[string]any{"v": 1}, Time: time.Unix(1, 0)}})
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
if len(srv.bodies) != 1 {
|
|
t.Fatalf("expected 1 attempt, got %d", len(srv.bodies))
|
|
}
|
|
}
|
|
|
|
func TestWriteRetriesServerErrors(t *testing.T) {
|
|
srv := newCaptureServer(t)
|
|
srv.status.Store(http.StatusInternalServerError)
|
|
e := newTestExporter(t, srv, Config{FlushInterval: time.Hour, BatchSize: 1})
|
|
e.write([]Point{{Measurement: "m", Fields: map[string]any{"v": 1}, Time: time.Unix(1, 0)}})
|
|
srv.mu.Lock()
|
|
defer srv.mu.Unlock()
|
|
if len(srv.bodies) != writeAttempts {
|
|
t.Fatalf("expected %d attempts, got %d", writeAttempts, len(srv.bodies))
|
|
}
|
|
}
|