feat: implement ETL visibility tracking for API Gateway, logging, and tracing
- Add OpenTelemetry metrics middleware to HTTP server for RPS and latency - Implement HTTP request tracing with UUID trace IDs and context propagation - Introduce standardized ErrorCategory logic in logger package - Update docs/design/ETL_TODO.md with completed tasks Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
This commit is contained in:
parent
e99a5ae7f3
commit
6de54991ab
4 changed files with 214 additions and 6 deletions
|
|
@ -16,7 +16,7 @@ func TestNewAgentCommand(t *testing.T) {
|
||||||
assert.Equal(t, "Interact with the agent directly", cmd.Short)
|
assert.Equal(t, "Interact with the agent directly", cmd.Short)
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
assert.False(t, cmd.HasSubCommands())
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
assert.Nil(t, cmd.Run)
|
assert.Nil(t, cmd.Run)
|
||||||
assert.NotNil(t, cmd.RunE)
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@ This document tracks the tasks required to implement the "Ultimate Visibility" E
|
||||||
- [x] **Memory Tracking:** Implement a metric to track heap allocation and GC pauses.
|
- [x] **Memory Tracking:** Implement a metric to track heap allocation and GC pauses.
|
||||||
- [x] **AgentLoop Telemetry:** Add specific instrumentation to the `AgentLoop` (iteration duration, tool execution duration, failure counts).
|
- [x] **AgentLoop Telemetry:** Add specific instrumentation to the `AgentLoop` (iteration duration, tool execution duration, failure counts).
|
||||||
- [x] **LLM Provider Telemetry:** Track API call latency, token usage, and failover reasons for LLM providers.
|
- [x] **LLM Provider Telemetry:** Track API call latency, token usage, and failover reasons for LLM providers.
|
||||||
- [ ] **API Gateway Telemetry:** Track request rates (RPS), latency percentiles, and error rates for HTTP and WebSocket endpoints.
|
- [x] **API Gateway Telemetry:** Track request rates (RPS), latency percentiles, and error rates for HTTP and WebSocket endpoints.
|
||||||
- [ ] **Tracing Instrumentation:** Introduce trace IDs at entry points (HTTP, WebSocket) and propagate them via context to track end-to-end execution flow.
|
- [x] **Tracing Instrumentation:** Introduce trace IDs at entry points (HTTP, WebSocket) and propagate them via context to track end-to-end execution flow.
|
||||||
|
|
||||||
## 2. Transform (Stream Processing & Enrichment)
|
## 2. Transform (Stream Processing & Enrichment)
|
||||||
|
|
||||||
- [ ] **Log Normalization:** Standardize error classifications (e.g., Model Failure, Infrastructure Failure, Logic Failure) to ensure consistent log querying.
|
- [x] **Log Normalization:** Standardize error classifications (e.g., Model Failure, Infrastructure Failure, Logic Failure) to ensure consistent log querying.
|
||||||
- [ ] **Aggregation Strategy:** Design the pipeline for aggregating high-volume events before they reach the data warehouse (e.g., Vector.dev configuration).
|
- [ ] **Aggregation Strategy:** Design the pipeline for aggregating high-volume events before they reach the data warehouse (e.g., Vector.dev configuration).
|
||||||
|
|
||||||
## 3. Load (Storage & Analytics)
|
## 3. Load (Storage & Analytics)
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,94 @@
|
||||||
package channels
|
package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/metric"
|
||||||
|
|
||||||
"jane/pkg/health"
|
"jane/pkg/health"
|
||||||
"jane/pkg/logger"
|
"jane/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// telemetryMiddleware wraps an http.Handler to track request metrics using OpenTelemetry.
|
||||||
|
func telemetryMiddleware(next http.Handler) http.Handler {
|
||||||
|
meter := otel.Meter("jane/pkg/channels")
|
||||||
|
|
||||||
|
// Create metrics
|
||||||
|
requestCounter, err := meter.Int64Counter(
|
||||||
|
"http.server.requests",
|
||||||
|
metric.WithDescription("Total number of HTTP requests"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to create request counter metric", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
durationHistogram, err := meter.Float64Histogram(
|
||||||
|
"http.server.duration",
|
||||||
|
metric.WithDescription("HTTP request duration in milliseconds"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to create duration histogram metric", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Generate Trace ID and attach to context and response header
|
||||||
|
traceID := uuid.New().String()
|
||||||
|
w.Header().Set("X-Trace-Id", traceID)
|
||||||
|
ctx := context.WithValue(r.Context(), logger.TraceIDKey, traceID)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
// Wrap ResponseWriter to capture the status code
|
||||||
|
rw := &responseWriterWrapper{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
|
||||||
|
durationMs := float64(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
|
||||||
|
if requestCounter != nil {
|
||||||
|
requestCounter.Add(r.Context(), 1)
|
||||||
|
}
|
||||||
|
if durationHistogram != nil {
|
||||||
|
durationHistogram.Record(r.Context(), durationMs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// responseWriterWrapper captures the HTTP status code for metrics and logging
|
||||||
|
type responseWriterWrapper struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriterWrapper) WriteHeader(code int) {
|
||||||
|
rw.statusCode = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hijack implements the http.Hijacker interface, required for WebSockets
|
||||||
|
func (rw *responseWriterWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
hijacker, ok := rw.ResponseWriter.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking")
|
||||||
|
}
|
||||||
|
return hijacker.Hijack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush implements the http.Flusher interface, required for streaming
|
||||||
|
func (rw *responseWriterWrapper) Flush() {
|
||||||
|
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SetupHTTPServer creates a shared HTTP server with the given listen address.
|
// SetupHTTPServer creates a shared HTTP server with the given listen address.
|
||||||
// It registers health endpoints from the health server and discovers channels
|
// It registers health endpoints from the health server and discovers channels
|
||||||
// that implement WebhookHandler and/or HealthChecker to register their handlers.
|
// that implement WebhookHandler and/or HealthChecker to register their handlers.
|
||||||
|
|
@ -43,7 +124,7 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
|
||||||
|
|
||||||
m.httpServer = &http.Server{
|
m.httpServer = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: m.mux,
|
Handler: telemetryMiddleware(m.mux),
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -11,6 +12,22 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// contextKey is a custom type for context keys to avoid collisions
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// TraceIDKey is the context key for tracing requests
|
||||||
|
TraceIDKey contextKey = "traceID"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrorCategoryModelFailure ErrorCategory = "Model Failure"
|
||||||
|
ErrorCategoryInfrastructureFailure ErrorCategory = "Infrastructure Failure"
|
||||||
|
ErrorCategoryLogicFailure ErrorCategory = "Logic Failure"
|
||||||
|
)
|
||||||
|
|
||||||
type LogLevel = zerolog.Level
|
type LogLevel = zerolog.Level
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -161,7 +178,7 @@ func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func logMessage(level LogLevel, component string, message string, fields map[string]any) {
|
func logMessageCtx(ctx context.Context, level LogLevel, component string, message string, fields map[string]any) {
|
||||||
if level < currentLevel {
|
if level < currentLevel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +187,12 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
|
|
||||||
event := getEvent(logger, level)
|
event := getEvent(logger, level)
|
||||||
|
|
||||||
|
if ctx != nil {
|
||||||
|
if traceID, ok := ctx.Value(TraceIDKey).(string); ok && traceID != "" {
|
||||||
|
event.Str("trace_id", traceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build combined field with component and caller
|
// Build combined field with component and caller
|
||||||
if component != "" {
|
if component != "" {
|
||||||
event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
|
event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
|
||||||
|
|
@ -187,6 +210,12 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
if fileLogger.GetLevel() != zerolog.NoLevel {
|
if fileLogger.GetLevel() != zerolog.NoLevel {
|
||||||
fileEvent := getEvent(fileLogger, level)
|
fileEvent := getEvent(fileLogger, level)
|
||||||
|
|
||||||
|
if ctx != nil {
|
||||||
|
if traceID, ok := ctx.Value(TraceIDKey).(string); ok && traceID != "" {
|
||||||
|
fileEvent.Str("trace_id", traceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if component != "" {
|
if component != "" {
|
||||||
fileEvent.Str("component", component)
|
fileEvent.Str("component", component)
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +230,104 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logMessage(level LogLevel, component string, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(nil, level, component, message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DebugCtx(ctx context.Context, message string) {
|
||||||
|
logMessageCtx(ctx, DEBUG, "", message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DebugCCtx(ctx context.Context, component string, message string) {
|
||||||
|
logMessageCtx(ctx, DEBUG, component, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DebugFCtx(ctx context.Context, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, DEBUG, "", message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DebugCFCtx(ctx context.Context, component string, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, DEBUG, component, message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InfoCtx(ctx context.Context, message string) {
|
||||||
|
logMessageCtx(ctx, INFO, "", message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InfoCCtx(ctx context.Context, component string, message string) {
|
||||||
|
logMessageCtx(ctx, INFO, component, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InfoFCtx(ctx context.Context, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, INFO, "", message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InfoCFCtx(ctx context.Context, component string, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, INFO, component, message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WarnCtx(ctx context.Context, message string) {
|
||||||
|
logMessageCtx(ctx, WARN, "", message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WarnCCtx(ctx context.Context, component string, message string) {
|
||||||
|
logMessageCtx(ctx, WARN, component, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WarnFCtx(ctx context.Context, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, WARN, "", message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WarnCFCtx(ctx context.Context, component string, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, WARN, component, message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrorCtx(ctx context.Context, message string) {
|
||||||
|
logMessageCtx(ctx, ERROR, "", message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrorCCtx(ctx context.Context, component string, message string) {
|
||||||
|
logMessageCtx(ctx, ERROR, component, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrorFCtx(ctx context.Context, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, ERROR, "", message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrorCFCtx(ctx context.Context, component string, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, ERROR, component, message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FatalCtx(ctx context.Context, message string) {
|
||||||
|
logMessageCtx(ctx, FATAL, "", message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FatalCCtx(ctx context.Context, component string, message string) {
|
||||||
|
logMessageCtx(ctx, FATAL, component, message, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FatalfCtx(ctx context.Context, message string, ss ...any) {
|
||||||
|
logMessageCtx(ctx, FATAL, "", fmt.Sprintf(message, ss...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FatalFCtx(ctx context.Context, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, FATAL, "", message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FatalCFCtx(ctx context.Context, component string, message string, fields map[string]any) {
|
||||||
|
logMessageCtx(ctx, FATAL, component, message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogErrorWithCategory(ctx context.Context, category ErrorCategory, message string, err error) {
|
||||||
|
fields := map[string]any{
|
||||||
|
"error_category": string(category),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
}
|
||||||
|
logMessageCtx(ctx, ERROR, "", message, fields)
|
||||||
|
}
|
||||||
|
|
||||||
func Debug(message string) {
|
func Debug(message string) {
|
||||||
logMessage(DEBUG, "", message, nil)
|
logMessage(DEBUG, "", message, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue