chore: initial commit of Go API gateway
Baseline for the Kong replacement on api.riotpiao.com. Brings the working tree under version control for the first time: gateway source, the task board that drives the agent runs, test fixtures, and K8s manifests. Anchor the gateway ignore rule to the repo root. Unanchored, "gateway" also matched the cmd/gateway/ source directory, so the program entrypoint was excluded from every commit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
// Package logging provides structured JSON logging for the gateway.
|
||||
// All logs are emitted as single-line JSON records.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level represents a log level.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelWarn
|
||||
LevelError
|
||||
)
|
||||
|
||||
// Logger provides structured logging with sensitive data redaction.
|
||||
type Logger struct {
|
||||
level Level
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
// New creates a new structured logger with the given level writing to out.
|
||||
func New(level Level, out io.Writer) *Logger {
|
||||
if out == nil {
|
||||
out = os.Stderr
|
||||
}
|
||||
return &Logger{level: level, out: out}
|
||||
}
|
||||
|
||||
// ParseLevel parses a log level string (debug, info, warn, error).
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "debug":
|
||||
return LevelDebug
|
||||
case "info":
|
||||
return LevelInfo
|
||||
case "warn":
|
||||
return LevelWarn
|
||||
case "error":
|
||||
return LevelError
|
||||
default:
|
||||
return LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// LogRecord is a single structured log entry.
|
||||
type LogRecord struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Route string `json:"route,omitempty"`
|
||||
Upstream string `json:"upstream,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// RequestLog logs a request with response information.
|
||||
func (l *Logger) RequestLog(route, upstream, method, path string, status int, duration time.Duration, reason string) {
|
||||
if l.level > LevelInfo {
|
||||
return
|
||||
}
|
||||
record := LogRecord{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Level: "info",
|
||||
Message: "request",
|
||||
Route: route,
|
||||
Upstream: upstream,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Status: status,
|
||||
Duration: duration.String(),
|
||||
Reason: reason,
|
||||
}
|
||||
l.emit(record)
|
||||
}
|
||||
|
||||
// Infof logs an info-level message.
|
||||
func (l *Logger) Infof(msg string, fields map[string]string) {
|
||||
if l.level > LevelInfo {
|
||||
return
|
||||
}
|
||||
record := LogRecord{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Level: "info",
|
||||
Message: msg,
|
||||
Extra: fields,
|
||||
}
|
||||
l.emit(record)
|
||||
}
|
||||
|
||||
// Warnf logs a warn-level message.
|
||||
func (l *Logger) Warnf(msg string, fields map[string]string) {
|
||||
if l.level > LevelWarn {
|
||||
return
|
||||
}
|
||||
record := LogRecord{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Level: "warn",
|
||||
Message: msg,
|
||||
Extra: fields,
|
||||
}
|
||||
l.emit(record)
|
||||
}
|
||||
|
||||
// Errorf logs an error-level message.
|
||||
func (l *Logger) Errorf(msg string, err error, fields map[string]string) {
|
||||
if l.level > LevelError {
|
||||
return
|
||||
}
|
||||
errStr := ""
|
||||
if err != nil {
|
||||
errStr = err.Error()
|
||||
}
|
||||
record := LogRecord{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Level: "error",
|
||||
Message: msg,
|
||||
Error: errStr,
|
||||
Extra: fields,
|
||||
}
|
||||
l.emit(record)
|
||||
}
|
||||
|
||||
// emit writes a record as a single JSON line.
|
||||
func (l *Logger) emit(record LogRecord) {
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
log.Printf("failed to marshal log record: %v", err)
|
||||
return
|
||||
}
|
||||
_, _ = l.out.Write(append(data, '\n'))
|
||||
}
|
||||
|
||||
// global is the singleton logger instance.
|
||||
var global *Logger
|
||||
|
||||
// Init initializes the global logger with the given level.
|
||||
// If not called, defaults to Info level on stderr.
|
||||
func Init(level Level, out io.Writer) {
|
||||
global = New(level, out)
|
||||
}
|
||||
|
||||
// ensure global is initialized
|
||||
func ensure() {
|
||||
if global == nil {
|
||||
global = New(LevelInfo, os.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// RequestLog logs a request with response information using the global logger.
|
||||
func RequestLog(route, upstream, method, path string, status int, duration time.Duration, reason string) {
|
||||
ensure()
|
||||
global.RequestLog(route, upstream, method, path, status, duration, reason)
|
||||
}
|
||||
|
||||
// Infof logs an info message using the global logger.
|
||||
func Infof(msg string, fields map[string]string) {
|
||||
ensure()
|
||||
global.Infof(msg, fields)
|
||||
}
|
||||
|
||||
// Warnf logs a warn message using the global logger.
|
||||
func Warnf(msg string, fields map[string]string) {
|
||||
ensure()
|
||||
global.Warnf(msg, fields)
|
||||
}
|
||||
|
||||
// Errorf logs an error message using the global logger.
|
||||
func Errorf(msg string, err error, fields map[string]string) {
|
||||
ensure()
|
||||
global.Errorf(msg, err, fields)
|
||||
}
|
||||
|
||||
// FromContext retrieves the logger from a context, or returns the global logger.
|
||||
func FromContext(ctx context.Context) *Logger {
|
||||
ensure()
|
||||
if l, ok := ctx.Value("logger").(*Logger); ok {
|
||||
return l
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
// WithContext returns a new context with the logger attached.
|
||||
func WithContext(ctx context.Context, l *Logger) context.Context {
|
||||
return context.WithValue(ctx, "logger", l)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLogStructure(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, buf)
|
||||
|
||||
logger.RequestLog("test-route", "localhost:8080", "POST", "/v1/chat/completions", 200, 100*time.Millisecond, "")
|
||||
|
||||
lines := strings.TrimSpace(buf.String())
|
||||
if lines == "" {
|
||||
t.Fatal("expected log output, got empty")
|
||||
}
|
||||
|
||||
var record LogRecord
|
||||
if err := json.Unmarshal([]byte(lines), &record); err != nil {
|
||||
t.Fatalf("failed to unmarshal log record: %v", err)
|
||||
}
|
||||
|
||||
if record.Level != "info" {
|
||||
t.Errorf("expected level info, got %s", record.Level)
|
||||
}
|
||||
if record.Route != "test-route" {
|
||||
t.Errorf("expected route test-route, got %s", record.Route)
|
||||
}
|
||||
if record.Upstream != "localhost:8080" {
|
||||
t.Errorf("expected upstream localhost:8080, got %s", record.Upstream)
|
||||
}
|
||||
if record.Method != "POST" {
|
||||
t.Errorf("expected method POST, got %s", record.Method)
|
||||
}
|
||||
if record.Path != "/v1/chat/completions" {
|
||||
t.Errorf("expected path /v1/chat/completions, got %s", record.Path)
|
||||
}
|
||||
if record.Status != 200 {
|
||||
t.Errorf("expected status 200, got %d", record.Status)
|
||||
}
|
||||
if record.Duration != (100 * time.Millisecond).String() {
|
||||
t.Errorf("expected duration 100ms, got %s", record.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedRequestReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
}{
|
||||
{"unknown_model", "unknown_model"},
|
||||
{"body_too_large", "body_too_large"},
|
||||
{"auth_failed", "auth_failed"},
|
||||
{"concurrency_limit", "concurrency_limit"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, buf)
|
||||
|
||||
logger.RequestLog("test", "upstream", "POST", "/path", 400, 10*time.Millisecond, tt.reason)
|
||||
|
||||
var record LogRecord
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &record); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if record.Reason != tt.reason {
|
||||
t.Errorf("expected reason %s, got %s", tt.reason, record.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBodyLogging(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, buf)
|
||||
|
||||
// Simulate logging with fields that might contain body-like data
|
||||
logger.RequestLog("route", "upstream", "POST", "/path", 400, time.Millisecond, "reason")
|
||||
|
||||
output := buf.String()
|
||||
// Ensure no field named "body" appears in the output
|
||||
if strings.Contains(output, "\"body\"") {
|
||||
t.Errorf("request body should not be logged, but found 'body' field in: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoTokenLogging(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, buf)
|
||||
|
||||
// Log a rejected request as it would happen with auth failure
|
||||
logger.RequestLog("route", "upstream", "POST", "/path", 401, time.Millisecond, "auth_failed")
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Ensure no Authorization header value appears
|
||||
testToken := "sk-1234567890abcdef"
|
||||
if strings.Contains(output, testToken) {
|
||||
t.Errorf("bearer token should not be logged, but found in output: %s", output)
|
||||
}
|
||||
|
||||
// Ensure Authorization header field doesn't appear
|
||||
if strings.Contains(output, "Authorization") {
|
||||
t.Errorf("Authorization header should not be logged, but found in output: %s", output)
|
||||
}
|
||||
|
||||
// Ensure "bearer" or "token" keywords don't appear in the output
|
||||
lowerOutput := strings.ToLower(output)
|
||||
if strings.Contains(lowerOutput, "bearer") {
|
||||
t.Errorf("bearer token substring should not appear, but found in output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsistentFieldSet(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, buf)
|
||||
|
||||
logger.RequestLog("route", "upstream", "GET", "/healthz", 200, time.Millisecond, "")
|
||||
|
||||
var record LogRecord
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &record); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// All required fields should be present
|
||||
if record.Timestamp == "" {
|
||||
t.Error("timestamp field missing")
|
||||
}
|
||||
if record.Level == "" {
|
||||
t.Error("level field missing")
|
||||
}
|
||||
if record.Message == "" {
|
||||
t.Error("message field missing")
|
||||
}
|
||||
if record.Route == "" {
|
||||
t.Error("route field missing")
|
||||
}
|
||||
if record.Upstream == "" {
|
||||
t.Error("upstream field missing")
|
||||
}
|
||||
if record.Method == "" {
|
||||
t.Error("method field missing")
|
||||
}
|
||||
if record.Path == "" {
|
||||
t.Error("path field missing")
|
||||
}
|
||||
if record.Status == 0 {
|
||||
t.Error("status field missing")
|
||||
}
|
||||
if record.Duration == "" {
|
||||
t.Error("duration field missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogLevelFiltering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level Level
|
||||
shouldLog bool
|
||||
}{
|
||||
{"debug_level", LevelDebug, true},
|
||||
{"info_level", LevelInfo, true},
|
||||
{"warn_level_info_msg", LevelWarn, false},
|
||||
{"error_level_info_msg", LevelError, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(tt.level, buf)
|
||||
|
||||
logger.RequestLog("route", "upstream", "GET", "/path", 200, time.Millisecond, "")
|
||||
|
||||
if tt.shouldLog && buf.Len() == 0 {
|
||||
t.Errorf("expected log output at level %d, got empty", tt.level)
|
||||
}
|
||||
if !tt.shouldLog && buf.Len() > 0 {
|
||||
t.Errorf("expected no log output at level %d, got: %s", tt.level, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogLineFormat(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, buf)
|
||||
|
||||
logger.RequestLog("route", "upstream", "POST", "/path", 201, time.Millisecond, "created")
|
||||
|
||||
output := strings.TrimSpace(buf.String())
|
||||
if !strings.HasSuffix(output, "\n") && buf.String() != output {
|
||||
// Single line with newline
|
||||
if !strings.Contains(buf.String(), "\n") {
|
||||
t.Error("expected newline-terminated log line")
|
||||
}
|
||||
}
|
||||
|
||||
// Must be valid JSON
|
||||
var record LogRecord
|
||||
if err := json.Unmarshal([]byte(output), &record); err != nil {
|
||||
t.Errorf("log output is not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedRequestExactlyOneRecord(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, buf)
|
||||
|
||||
// Simulate a rejected request
|
||||
logger.RequestLog("test-route", "upstream:8080", "POST", "/v1/chat/completions", 401, 5*time.Millisecond, "auth_failed")
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("expected exactly one log record, got %d", len(lines))
|
||||
}
|
||||
|
||||
var record LogRecord
|
||||
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// Verify reason is present and meaningful
|
||||
if record.Reason != "auth_failed" {
|
||||
t.Errorf("expected reason 'auth_failed', got '%s'", record.Reason)
|
||||
}
|
||||
|
||||
// Verify no token-like strings appear
|
||||
recordJSON, _ := json.Marshal(record)
|
||||
recordStr := string(recordJSON)
|
||||
if strings.Contains(recordStr, "Bearer") || strings.Contains(recordStr, "bearer") ||
|
||||
strings.Contains(recordStr, "Authorization") || strings.Contains(recordStr, "authorization") {
|
||||
t.Errorf("rejected request log should not contain token/auth header substring: %s", recordStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected Level
|
||||
}{
|
||||
{"debug", LevelDebug},
|
||||
{"info", LevelInfo},
|
||||
{"warn", LevelWarn},
|
||||
{"error", LevelError},
|
||||
{"DEBUG", LevelDebug},
|
||||
{"Info", LevelInfo},
|
||||
{"unknown", LevelInfo},
|
||||
{"", LevelInfo},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := ParseLevel(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("expected %d, got %d", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user