zoobzio December 12, 2025 Edit this page

HTTP Server Observability

Complete example of instrumenting an HTTP server with aperture.

Signals and Keys

package signals

import "github.com/zoobz-io/capitan"

// HTTP request lifecycle
var (
    HTTPRequestReceived  = capitan.NewSignal("http.request.received", "HTTP request received")
    HTTPRequestCompleted = capitan.NewSignal("http.request.completed", "HTTP request completed")
)

// Field keys
var (
    RequestID  = capitan.NewStringKey("request_id")
    Method     = capitan.NewStringKey("method")
    Path       = capitan.NewStringKey("path")
    StatusCode = capitan.NewIntKey("status")
    Duration   = capitan.NewDurationKey("duration")
    UserID     = capitan.NewStringKey("user_id")
)

Configuration

package main

import "github.com/zoobz-io/aperture"

func observabilitySchema() aperture.Schema {
    return aperture.Schema{
        Metrics: []aperture.MetricSchema{
            // Count requests
            {
                Signal:      "http.request.completed",
                Name:        "http_requests_total",
                Type:        "counter",
                Description: "Total HTTP requests",
            },
            // Latency histogram
            {
                Signal:      "http.request.completed",
                Name:        "http_request_duration_ms",
                Type:        "histogram",
                ValueKey:    "duration",
                Description: "HTTP request latency distribution",
            },
        },
        Traces: []aperture.TraceSchema{
            {
                Start:          "http.request.received",
                End:            "http.request.completed",
                CorrelationKey: "request_id",
                SpanName:       "http_request",
            },
        },
        Logs: &aperture.LogSchema{
            Whitelist: []string{
                "http.request.received",
                "http.request.completed",
            },
        },
    }
}

Middleware

package middleware

import (
    "context"
    "net/http"
    "time"

    "github.com/google/uuid"
    "github.com/zoobz-io/capitan"
    "myapp/signals"
)

type ctxKey string

const requestIDKey ctxKey = "request_id"

func Observability(cap *capitan.Capitan) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := r.Context()
            start := time.Now()

            // Generate request ID
            reqID := r.Header.Get("X-Request-ID")
            if reqID == "" {
                reqID = uuid.New().String()
            }
            ctx = context.WithValue(ctx, requestIDKey, reqID)

            // Emit request received
            cap.Emit(ctx, signals.HTTPRequestReceived,
                signals.RequestID.Field(reqID),
                signals.Method.Field(r.Method),
                signals.Path.Field(r.URL.Path),
            )

            // Wrap response writer to capture status
            wrapped := &statusWriter{ResponseWriter: w, status: http.StatusOK}

            // Process request
            next.ServeHTTP(wrapped, r.WithContext(ctx))

            // Emit request completed
            cap.Emit(ctx, signals.HTTPRequestCompleted,
                signals.RequestID.Field(reqID),
                signals.Method.Field(r.Method),
                signals.Path.Field(r.URL.Path),
                signals.StatusCode.Field(wrapped.status),
                signals.Duration.Field(time.Since(start)),
            )
        })
    }
}

type statusWriter struct {
    http.ResponseWriter
    status int
}

func (w *statusWriter) WriteHeader(status int) {
    w.status = status
    w.ResponseWriter.WriteHeader(status)
}

Main Application

package main

import (
    "context"
    "log"
    "net/http"
    "time"

    "github.com/zoobz-io/aperture"
    "github.com/zoobz-io/capitan"
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    sdklog "go.opentelemetry.io/otel/sdk/log"
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.28.0"
    "myapp/middleware"
)

func main() {
    ctx := context.Background()

    // Create resource
    res, _ := resource.Merge(
        resource.Default(),
        resource.NewSchemaless(
            semconv.ServiceName("my-api"),
            semconv.ServiceVersion("v1.0.0"),
        ),
    )

    // Create providers
    logExporter, _ := otlploghttp.New(ctx, otlploghttp.WithEndpoint("localhost:4318"), otlploghttp.WithInsecure())
    logProvider := sdklog.NewLoggerProvider(
        sdklog.WithResource(res),
        sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
    )
    defer logProvider.Shutdown(ctx)

    metricExporter, _ := otlpmetrichttp.New(ctx, otlpmetrichttp.WithEndpoint("localhost:4318"), otlpmetrichttp.WithInsecure())
    meterProvider := sdkmetric.NewMeterProvider(
        sdkmetric.WithResource(res),
        sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter, sdkmetric.WithInterval(60*time.Second))),
    )
    defer meterProvider.Shutdown(ctx)

    traceExporter, _ := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint("localhost:4318"), otlptracehttp.WithInsecure())
    traceProvider := sdktrace.NewTracerProvider(
        sdktrace.WithResource(res),
        sdktrace.WithSpanProcessor(sdktrace.NewBatchSpanProcessor(traceExporter)),
        sdktrace.WithSampler(sdktrace.AlwaysSample()),
    )
    defer traceProvider.Shutdown(ctx)

    // Create capitan and aperture
    cap := capitan.Default()
    defer cap.Shutdown()

    ap, err := aperture.New(cap, logProvider, meterProvider, traceProvider)
    if err != nil {
        log.Fatal(err)
    }
    defer ap.Close()

    // Apply observability schema
    ap.Apply(observabilitySchema())

    // Create HTTP server with observability middleware
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })

    handler := middleware.Observability(cap)(mux)

    log.Println("Starting server on :8080")
    http.ListenAndServe(":8080", handler)
}

Resulting Telemetry

Metrics

http_requests_total{method="GET", path="/", status="200"} 42
http_request_duration_ms_bucket{method="GET", path="/", le="10"} 5
http_request_duration_ms_bucket{method="GET", path="/", le="50"} 35
http_request_duration_ms_bucket{method="GET", path="/", le="100"} 41
http_request_duration_ms_bucket{method="GET", path="/", le="+Inf"} 42

Logs

{
  "timestamp": "2025-01-15T10:30:00Z",
  "severity": "INFO",
  "body": "HTTP request received",
  "attributes": {
    "capitan.signal": "http.request.received",
    "request_id": "abc-123",
    "method": "GET",
    "path": "/"
  }
}

Traces

Span: http_request
  TraceID: abc123...
  Duration: 45ms
  Attributes:
    - request_id: abc-123
    - method: GET
    - path: /
    - status: 200
    - duration: 45000000

With Context Extraction

Add user ID from authentication:

type ctxKey string
const userIDKey ctxKey = "user_id"

ap.RegisterContextKey("user_id", userIDKey)

schema := observabilitySchema()
schema.Context = &aperture.ContextSchema{
    Logs:   []string{"user_id"},
    Traces: []string{"user_id"},
}
ap.Apply(schema)

Then in auth middleware:

ctx = context.WithValue(ctx, userIDKey, authenticatedUserID)

All logs and traces automatically include user_id.