Skip to content

Commit 5c4564f

Browse files
authored
Merge pull request #18 from Semih702/ci/go-quality-and-tests
Go CI: unit tests, race-enabled checks, and cmd/internal structure
2 parents 55cdd19 + 57b12a7 commit 5c4564f

25 files changed

Lines changed: 975 additions & 725 deletions

File tree

.github/workflows/go-ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ jobs:
6565
set -euo pipefail
6666
go vet ./...
6767
68-
- name: go test
68+
- name: go test (race)
6969
working-directory: ${{ matrix.module }}
7070
run: |
7171
set -euo pipefail
72-
go test ./...
72+
go test -race ./...

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,8 +372,8 @@ This pipeline exists to ensure the Helm chart remains:
372372
- Add CI checks for Go code quality:
373373
- `go test ./...` (with `-race` where feasible)
374374
- `golangci-lint` (or at least `go vet`)
375-
- formatting checks (`gofmt`) and module tidiness (`go mod tidy` / `go mod verify`)
376-
- Keep these checks fast to run on every PR, and required before release
375+
- formatting checks (`gofmt`) and module tidiness (`go mod tidy` / `go mod verify`)
376+
- Keep these checks fast to run on every PR, and required before release
377377

378378
### Streaming (SSE) – final validation (edge cases)
379379
- Client disconnect propagation (client → proxy → upstream)

collector/Dockerfile

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
# -------- build stage --------
2-
FROM golang:1.22-alpine AS builder
3-
4-
WORKDIR /src
5-
COPY go.mod ./
6-
RUN go mod download
7-
8-
COPY . .
9-
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/llm-collector .
10-
11-
# -------- runtime stage --------
12-
FROM gcr.io/distroless/static:nonroot
13-
14-
WORKDIR /
15-
COPY --from=builder /out/llm-collector /llm-collector
16-
17-
EXPOSE 8081
18-
USER nonroot:nonroot
19-
20-
ENTRYPOINT ["/llm-collector"]
1+
# -------- build stage --------
2+
FROM golang:1.22-alpine AS builder
3+
4+
WORKDIR /src
5+
COPY go.mod go.sum ./
6+
RUN go mod download
7+
8+
COPY . .
9+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
10+
go build -trimpath -ldflags="-s -w" -o /out/llm-collector ./cmd/collector
11+
12+
# -------- runtime stage --------
13+
FROM gcr.io/distroless/static:nonroot
14+
15+
WORKDIR /
16+
COPY --from=builder /out/llm-collector /llm-collector
17+
18+
EXPOSE 8081
19+
USER nonroot:nonroot
20+
21+
ENTRYPOINT ["/llm-collector"]

collector/cmd/collector/main.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"net/http"
6+
"time"
7+
8+
collector "llm-collector/internal/collector"
9+
)
10+
11+
func main() {
12+
addr := ":" + collector.Getenv("PORT", "8081")
13+
outPath := collector.Getenv("EVENT_LOG_PATH", "")
14+
15+
s, err := collector.NewServer(outPath)
16+
if err != nil {
17+
log.Fatalf("open log file: %v", err)
18+
}
19+
defer s.Close()
20+
21+
log.Printf("collector listening on %s", addr)
22+
23+
srv := &http.Server{
24+
Addr: addr,
25+
Handler: s.Mux(),
26+
ReadHeaderTimeout: 5 * time.Second,
27+
}
28+
29+
log.Fatal(srv.ListenAndServe())
30+
}

collector/go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
module llm-collector
22

33
go 1.22
4+
5+
require github.com/stretchr/testify v1.9.0
6+
7+
require (
8+
github.com/davecgh/go-spew v1.1.1 // indirect
9+
github.com/pmezard/go-difflib v1.0.0 // indirect
10+
gopkg.in/yaml.v3 v3.0.1 // indirect
11+
)

collector/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
6+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package collector
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"log"
7+
"net/http"
8+
"os"
9+
"sync"
10+
"time"
11+
)
12+
13+
type Server struct {
14+
mu sync.Mutex
15+
file *os.File
16+
w *bufio.Writer
17+
}
18+
19+
// NewServer creates a server. If outPath is empty, events are printed to stdout.
20+
func NewServer(outPath string) (*Server, error) {
21+
s := &Server{}
22+
if outPath == "" {
23+
log.Printf("collector: EVENT_LOG_PATH not set; events will be printed to stdout")
24+
return s, nil
25+
}
26+
27+
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
s.file = f
33+
s.w = bufio.NewWriterSize(f, 1<<20)
34+
log.Printf("collector: writing events to %s", outPath)
35+
return s, nil
36+
}
37+
38+
// Close flushes and closes the underlying file if configured.
39+
func (s *Server) Close() {
40+
s.mu.Lock()
41+
defer s.mu.Unlock()
42+
43+
if s.w != nil {
44+
_ = s.w.Flush()
45+
}
46+
if s.file != nil {
47+
_ = s.file.Close()
48+
}
49+
}
50+
51+
func (s *Server) Mux() *http.ServeMux {
52+
mux := http.NewServeMux()
53+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
54+
w.WriteHeader(http.StatusOK)
55+
_, _ = w.Write([]byte("ok"))
56+
})
57+
mux.HandleFunc("/events", s.HandleEvents)
58+
return mux
59+
}
60+
61+
func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) {
62+
if r.Method != http.MethodPost {
63+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
64+
return
65+
}
66+
67+
var ev MeteringEvent
68+
dec := json.NewDecoder(r.Body)
69+
dec.DisallowUnknownFields()
70+
if err := dec.Decode(&ev); err != nil {
71+
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
72+
return
73+
}
74+
75+
if ev.RequestID == "" {
76+
http.Error(w, "missing request_id", http.StatusBadRequest)
77+
return
78+
}
79+
if ev.At.IsZero() {
80+
ev.At = time.Now().UTC()
81+
}
82+
83+
b, err := json.Marshal(ev)
84+
if err != nil {
85+
http.Error(w, "failed to marshal", http.StatusInternalServerError)
86+
return
87+
}
88+
89+
s.mu.Lock()
90+
defer s.mu.Unlock()
91+
92+
if s.w != nil {
93+
_, _ = s.w.Write(b)
94+
_, _ = s.w.WriteString("\n")
95+
_ = s.w.Flush()
96+
} else {
97+
log.Printf("EVENT %s", string(b))
98+
}
99+
100+
w.WriteHeader(http.StatusAccepted)
101+
_, _ = w.Write([]byte("accepted"))
102+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package collector
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestHandleEvents_Accepted(t *testing.T) {
15+
s, err := NewServer("") // stdout mode
16+
require.NoError(t, err)
17+
18+
ev := MeteringEvent{
19+
RequestID: "req_123",
20+
Tenant: "default",
21+
Provider: "openai",
22+
Model: "gpt-4",
23+
At: time.Now().UTC(),
24+
}
25+
26+
body, _ := json.Marshal(ev)
27+
req := httptest.NewRequest(http.MethodPost, "/events", bytes.NewReader(body))
28+
rec := httptest.NewRecorder()
29+
30+
s.HandleEvents(rec, req)
31+
32+
require.Equal(t, http.StatusAccepted, rec.Code)
33+
require.Contains(t, rec.Body.String(), "accepted")
34+
}
35+
36+
func TestHandleEvents_InvalidJSON(t *testing.T) {
37+
s, err := NewServer("")
38+
require.NoError(t, err)
39+
40+
req := httptest.NewRequest(http.MethodPost, "/events", bytes.NewBufferString("{bad json"))
41+
rec := httptest.NewRecorder()
42+
43+
s.HandleEvents(rec, req)
44+
45+
require.Equal(t, http.StatusBadRequest, rec.Code)
46+
}
47+
48+
func TestHandleEvents_MissingRequestID(t *testing.T) {
49+
s, err := NewServer("")
50+
require.NoError(t, err)
51+
52+
ev := MeteringEvent{}
53+
body, _ := json.Marshal(ev)
54+
55+
req := httptest.NewRequest(http.MethodPost, "/events", bytes.NewReader(body))
56+
rec := httptest.NewRecorder()
57+
58+
s.HandleEvents(rec, req)
59+
60+
require.Equal(t, http.StatusBadRequest, rec.Code)
61+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package collector
2+
3+
import "time"
4+
5+
type MeteringEvent struct {
6+
RequestID string `json:"request_id"`
7+
Tenant string `json:"tenant"`
8+
AppKey string `json:"app_key"`
9+
Provider string `json:"provider"`
10+
Model string `json:"model"`
11+
PromptTokens int `json:"prompt_tokens"`
12+
CompletionTokens int `json:"completion_tokens"`
13+
TotalTokens int `json:"total_tokens"`
14+
LatencyMs int64 `json:"latency_ms"`
15+
StatusCode int `json:"status_code"`
16+
At time.Time `json:"ts"`
17+
Stream bool `json:"stream,omitempty"`
18+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package collector
2+
3+
import "os"
4+
5+
func Getenv(k, def string) string {
6+
v := os.Getenv(k)
7+
if v == "" {
8+
return def
9+
}
10+
return v
11+
}

0 commit comments

Comments
 (0)