API / plans /performance-observability.md
sshinmen's picture
Update HF Spaces deployment with latest changes
2e6b65c
|
Raw
History Blame Contribute Delete
18.6 kB
# Performance & Observability Enhancement Plan
## Current State Analysis
**Observability**:
- Basic logging with logrus
- No structured logging standards
- No distributed tracing
- No metrics collection
- No health check endpoints
**Performance**:
- No caching layer
- No connection pooling for upstream APIs
- No circuit breakers
- No request coalescing
- No rate limiting per API key
## Phase 1: Structured Logging (Week 1)
### 1.1 Migrate to slog (Go 1.21+)
**File**: `internal/infrastructure/logging/logger.go`
```go
package logging
import (
"context"
"log/slog"
"os"
"github.com/google/uuid"
)
// Logger wraps slog with application-specific methods
type Logger struct {
*slog.Logger
}
// Config for logger
type Config struct {
Level string `json:"level"`
Format string `json:"format"` // json or text
Output string `json:"output"` // stdout, stderr, or file path
}
func New(cfg Config) (*Logger, error) {
level := parseLevel(cfg.Level)
var handler slog.Handler
opts := &slog.HandlerOptions{
Level: level,
AddSource: true,
}
switch cfg.Format {
case "json":
handler = slog.NewJSONHandler(os.Stdout, opts)
case "text":
handler = slog.NewTextHandler(os.Stdout, opts)
default:
handler = slog.NewJSONHandler(os.Stdout, opts)
}
return &Logger{Logger: slog.New(handler)}, nil
}
// WithContext adds request context fields
func (l *Logger) WithContext(ctx context.Context) *Logger {
return &Logger{
Logger: l.Logger.With(
"request_id", ctx.Value(RequestIDKey{}),
"trace_id", ctx.Value(TraceIDKey{}),
),
}
}
// WithError adds error field
func (l *Logger) WithError(err error) *Logger {
return &Logger{
Logger: l.Logger.With("error", err),
}
}
// WithField adds a single field
func (l *Logger) WithField(key string, value interface{}) *Logger {
return &Logger{
Logger: l.Logger.With(key, value),
}
}
// WithFields adds multiple fields
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
attrs := make([]slog.Attr, 0, len(fields))
for k, v := range fields {
attrs = append(attrs, slog.Any(k, v))
}
return &Logger{Logger: slog.New(l.Logger.Handler().WithAttrs(attrs))}
}
// Request logging helper
func (l *Logger) LogRequest(
ctx context.Context,
method string,
path string,
status int,
duration time.Duration,
extra map[string]interface{},
) {
fields := map[string]interface{}{
"http.method": method,
"http.path": path,
"http.status": status,
"duration_ms": duration.Milliseconds(),
"duration_bucket": durationBucket(duration),
}
for k, v := range extra {
fields[k] = v
}
logger := l.WithContext(ctx).WithFields(fields)
switch {
case status >= 500:
logger.Error("HTTP request completed with server error")
case status >= 400:
logger.Warn("HTTP request completed with client error")
case status >= 300:
logger.Info("HTTP request redirected")
default:
logger.Info("HTTP request completed")
}
}
func durationBucket(d time.Duration) string {
switch {
case d < 10*time.Millisecond:
return "<10ms"
case d < 50*time.Millisecond:
return "10-50ms"
case d < 100*time.Millisecond:
return "50-100ms"
case d < 500*time.Millisecond:
return "100-500ms"
case d < 1*time.Second:
return "500ms-1s"
default:
return ">1s"
}
}
```
### 1.2 Gin Middleware
**File**: `internal/api/middleware/logging.go`
```go
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/echyai/cliproxyapi/internal/infrastructure/logging"
)
func RequestLogger(logger *logging.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
// Process request
c.Next()
// Log after request completes
duration := time.Since(start)
extra := map[string]interface{}{
"client_ip": c.ClientIP(),
"user_agent": c.Request.UserAgent(),
"errors": c.Errors.String(),
}
logger.LogRequest(
c.Request.Context(),
c.Request.Method,
c.Request.URL.Path,
c.Writer.Status(),
duration,
extra,
)
}
}
```
## Phase 2: Distributed Tracing (Week 2)
### 2.1 OpenTelemetry Setup
**File**: `internal/infrastructure/tracing/tracing.go`
```go
package tracing
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
var Tracer trace.Tracer
func Init(serviceName, jaegerEndpoint string) (*sdktrace.TracerProvider, error) {
exp, err := jaeger.New(jaeger.WithCollectorEndpoint(
jaeger.WithEndpoint(jaegerEndpoint),
))
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion("1.0.0"),
)),
)
otel.SetTracerProvider(tp)
Tracer = tp.Tracer(serviceName)
return tp, nil
}
// SpanFromContext retrieves current span
func SpanFromContext(ctx context.Context) trace.Span {
return trace.SpanFromContext(ctx)
}
// StartSpan starts a new span
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return Tracer.Start(ctx, name, opts...)
}
```
### 2.2 Gin Tracing Middleware
**File**: `internal/api/middleware/tracing.go`
```go
package middleware
import (
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"github.com/echyai/cliproxyapi/internal/infrastructure/tracing"
)
func Tracing() gin.HandlerFunc {
return func(c *gin.Context) {
// Extract trace context from headers
ctx := propagation.TraceContext{}.Extract(c.Request.Context(),
propagation.HeaderCarrier(c.Request.Header))
// Start span
ctx, span := tracing.StartSpan(ctx,
c.Request.Method+" "+c.FullPath(),
trace.WithAttributes(
semconv.HTTPMethod(c.Request.Method),
semconv.HTTPURL(c.Request.URL.String()),
semconv.HTTPUserAgent(c.Request.UserAgent()),
semconv.HTTPClientIP(c.ClientIP()),
),
)
defer span.End()
// Add span to context
c.Request = c.Request.WithContext(ctx)
// Continue
c.Next()
// Record result
span.SetAttributes(
semconv.HTTPStatusCode(c.Writer.Status()),
)
if c.Writer.Status() >= 400 {
span.RecordError(c.Errors.Last())
}
}
}
```
## Phase 3: Metrics Collection (Week 2-3)
### 3.1 Prometheus Metrics
**File**: `internal/infrastructure/metrics/metrics.go`
```go
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// HTTP metrics
HTTPRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cliproxy_http_requests_total",
Help: "Total HTTP requests",
},
[]string{"method", "path", "status"},
)
HTTPRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "cliproxy_http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
},
[]string{"method", "path"},
)
// Provider metrics
ProviderRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cliproxy_provider_requests_total",
Help: "Total requests to upstream providers",
},
[]string{"provider", "model", "status"},
)
ProviderRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "cliproxy_provider_request_duration_seconds",
Help: "Upstream provider request duration",
Buckets: []float64{.1, .25, .5, 1, 2.5, 5, 10, 30, 60},
},
[]string{"provider", "model"},
)
ProviderTokensTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cliproxy_provider_tokens_total",
Help: "Total tokens processed",
},
[]string{"provider", "model", "type"}, // type: input, output
)
// Rate limiting metrics
RateLimitHits = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cliproxy_rate_limit_hits_total",
Help: "Total rate limit hits",
},
[]string{"key_type", "key_id"},
)
// Cache metrics
CacheHits = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cliproxy_cache_hits_total",
Help: "Total cache hits",
},
[]string{"cache_name"},
)
CacheMisses = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "cliproxy_cache_misses_total",
Help: "Total cache misses",
},
[]string{"cache_name"},
)
// Active connections
ActiveConnections = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "cliproxy_active_connections",
Help: "Number of active connections",
},
)
// Auth metrics
ActiveAuths = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "cliproxy_active_auths",
Help: "Number of active authentications",
},
[]string{"provider"},
)
)
```
### 3.2 Gin Metrics Middleware
**File**: `internal/api/middleware/metrics.go`
```go
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/echyai/cliproxyapi/internal/infrastructure/metrics"
)
func PrometheusMetrics() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start).Seconds()
status := strconv.Itoa(c.Writer.Status())
path := c.FullPath()
if path == "" {
path = "unknown"
}
metrics.HTTPRequestsTotal.WithLabelValues(
c.Request.Method,
path,
status,
).Inc()
metrics.HTTPRequestDuration.WithLabelValues(
c.Request.Method,
path,
).Observe(duration)
}
}
```
## Phase 4: Caching Layer (Week 3)
### 4.1 Cache Interface
**File**: `internal/infrastructure/cache/cache.go`
```go
package cache
import (
"context"
"time"
)
// Cache interface for different backends
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
Delete(ctx context.Context, key string) error
GetSetMembers(ctx context.Context, key string) ([]string, error)
AddToSet(ctx context.Context, key string, members ...string) error
Close() error
}
// Config for cache
type Config struct {
Type string // redis, inmemory
Redis *RedisConfig
Memory *MemoryConfig
}
type RedisConfig struct {
Addr string
Password string
DB int
}
type MemoryConfig struct {
MaxSize int // Maximum number of items
}
```
### 4.2 Token Cache Implementation
**File**: `internal/infrastructure/cache/token_cache.go`
```go
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/echyai/cliproxyapi/internal/domain/entities"
)
// TokenCache provides caching for OAuth tokens
type TokenCache struct {
cache Cache
ttl time.Duration
}
func NewTokenCache(cache Cache, ttl time.Duration) *TokenCache {
return &TokenCache{
cache: cache,
ttl: ttl,
}
}
func (tc *TokenCache) Get(ctx context.Context, authID entities.AuthID) (*entities.TokenData, error) {
key := fmt.Sprintf("token:%s", authID)
data, err := tc.cache.Get(ctx, key)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
var token entities.TokenData
if err := json.Unmarshal(data, &token); err != nil {
return nil, err
}
return &token, nil
}
func (tc *TokenCache) Set(ctx context.Context, authID entities.AuthID, token *entities.TokenData) error {
key := fmt.Sprintf("token:%s", authID)
data, err := json.Marshal(token)
if err != nil {
return err
}
return tc.cache.Set(ctx, key, data, tc.ttl)
}
func (tc *TokenCache) Invalidate(ctx context.Context, authID entities.AuthID) error {
key := fmt.Sprintf("token:%s", authID)
return tc.cache.Delete(ctx, key)
}
```
## Phase 5: Circuit Breaker (Week 4)
### 5.1 Circuit Breaker Implementation
**File**: `internal/infrastructure/resilience/circuit_breaker.go`
```go
package resilience
import (
"context"
"errors"
"sync"
"time"
)
// State represents circuit breaker state
type State int
const (
StateClosed State = iota
StateOpen
StateHalfOpen
)
func (s State) String() string {
switch s {
case StateClosed:
return "closed"
case StateOpen:
return "open"
case StateHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// Config for circuit breaker
type Config struct {
FailureThreshold int // Number of failures before opening
SuccessThreshold int // Number of successes to close from half-open
Timeout time.Duration // Time before attempting half-open
HalfOpenMaxCalls int // Max calls allowed in half-open state
}
// CircuitBreaker implements the circuit breaker pattern
type CircuitBreaker struct {
name string
config Config
state State
failures int
successes int
lastFailure time.Time
halfOpenCalls int
mu sync.RWMutex
onStateChange func(name string, from, to State)
}
func New(name string, config Config, onStateChange func(string, State, State)) *CircuitBreaker {
return &CircuitBreaker{
name: name,
config: config,
state: StateClosed,
onStateChange: onStateChange,
}
}
func (cb *CircuitBreaker) Execute(ctx context.Context, fn func() error) error {
cb.mu.Lock()
state := cb.state
switch state {
case StateOpen:
if time.Since(cb.lastFailure) > cb.config.Timeout {
cb.transitionTo(StateHalfOpen)
state = StateHalfOpen
} else {
cb.mu.Unlock()
return errors.New("circuit breaker is open")
}
case StateHalfOpen:
if cb.halfOpenCalls >= cb.config.HalfOpenMaxCalls {
cb.mu.Unlock()
return errors.New("circuit breaker half-open call limit reached")
}
cb.halfOpenCalls++
}
cb.mu.Unlock()
// Execute function
err := fn()
cb.recordResult(err)
return err
}
func (cb *CircuitBreaker) recordResult(err error) {
cb.mu.Lock()
defer cb.mu.Unlock()
if err == nil {
cb.onSuccess()
} else {
cb.onFailure()
}
}
func (cb *CircuitBreaker) onSuccess() {
switch cb.state {
case StateHalfOpen:
cb.successes++
if cb.successes >= cb.config.SuccessThreshold {
cb.transitionTo(StateClosed)
}
default:
cb.failures = 0
}
}
func (cb *CircuitBreaker) onFailure() {
cb.failures++
cb.lastFailure = time.Now()
switch cb.state {
case StateHalfOpen:
cb.transitionTo(StateOpen)
default:
if cb.failures >= cb.config.FailureThreshold {
cb.transitionTo(StateOpen)
}
}
}
func (cb *CircuitBreaker) transitionTo(newState State) {
if cb.state != newState {
oldState := cb.state
cb.state = newState
cb.failures = 0
cb.successes = 0
cb.halfOpenCalls = 0
if cb.onStateChange != nil {
cb.onStateChange(cb.name, oldState, newState)
}
}
}
func (cb *CircuitBreaker) State() State {
cb.mu.RLock()
defer cb.mu.RUnlock()
return cb.state
}
```
### 5.2 Provider Client with Circuit Breaker
**File**: `internal/infrastructure/providers/gemini_client.go`
```go
package providers
import (
"context"
"fmt"
"time"
"github.com/echyai/cliproxyapi/internal/infrastructure/metrics"
"github.com/echyai/cliproxyapi/internal/infrastructure/resilience"
)
type GeminiClient struct {
baseURL string
apiKey string
httpClient *http.Client
circuitBreaker *resilience.CircuitBreaker
logger *logging.Logger
}
func NewGeminiClient(baseURL, apiKey string, logger *logging.Logger) *GeminiClient {
cb := resilience.New("gemini", resilience.Config{
FailureThreshold: 5,
SuccessThreshold: 2,
Timeout: 30 * time.Second,
HalfOpenMaxCalls: 3,
}, func(name string, from, to resilience.State) {
logger.WithFields(map[string]interface{}{
"circuit_breaker": name,
"from_state": from.String(),
"to_state": to.String(),
}).Warn("Circuit breaker state changed")
})
return &GeminiClient{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
circuitBreaker: cb,
logger: logger,
}
}
func (c *GeminiClient) GenerateContent(
ctx context.Context,
req *GenerateContentRequest,
) (*GenerateContentResponse, error) {
start := time.Now()
model := req.Model
var resp *GenerateContentResponse
var err error
cbErr := c.circuitBreaker.Execute(ctx, func() error {
resp, err = c.doGenerateContent(ctx, req)
return err
})
if cbErr != nil {
return nil, fmt.Errorf("circuit breaker: %w", cbErr)
}
// Record metrics
duration := time.Since(start).Seconds()
status := "success"
if err != nil {
status = "error"
}
metrics.ProviderRequestsTotal.WithLabelValues("gemini", model, status).Inc()
metrics.ProviderRequestDuration.WithLabelValues("gemini", model).Observe(duration)
if err != nil {
return nil, err
}
// Record token metrics
if resp.Usage != nil {
metrics.ProviderTokensTotal.WithLabelValues("gemini", model, "input").
Add(float64(resp.Usage.InputTokens))
metrics.ProviderTokensTotal.WithLabelValues("gemini", model, "output").
Add(float64(resp.Usage.OutputTokens))
}
return resp, nil
}
```
## Phase 6: Health Checks (Week 4)
**File**: `internal/api/handlers/health_handler.go`
```go
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/echyai/cliproxyapi/internal/infrastructure/health"
)
type HealthHandler struct {
checker *health.Checker
}
func NewHealthHandler(checker *health.Checker) *HealthHandler {
return &HealthHandler{checker: checker}
}
func (h *HealthHandler) Register(r *gin.RouterGroup) {
r.GET("/health", h.Liveness)
r.GET("/ready", h.Readiness)
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
}
func (h *HealthHandler) Liveness(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "alive",
"time": time.Now().UTC(),
})
}
func (h *HealthHandler) Readiness(c *gin.Context) {
checks := h.checker.RunAll(c.Request.Context())
status := http.StatusOK
for _, check := range checks {
if !check.Healthy {
status = http.StatusServiceUnavailable
break
}
}
c.JSON(status, gin.H{
"status": map[bool]string{true: "ready", false: "not_ready"}[status == http.StatusOK],
"checks": checks,
"version": version.Get(),
})
}
```
## Success Metrics
- [ ] < 50ms p99 latency for cached token lookups
- [ ] 99.9% availability measured via health checks
- [ ] < 1% request error rate
- [ ] All requests have distributed trace IDs
- [ ] Dashboard with key metrics in Grafana