Observability #

There’s a fundamental difference between systems that can be monitored and systems that can be observed. Monitored systems tell you when something is already broken — CPU at 95%, rising response times, error rates spiking. Observable systems let you answer any question about their internal conditions, including questions you never thought of before, just from the output the system produces.

This difference isn’t semantic. When a production incident happens at midnight, the difference between teams that identify root causes in 10 minutes and teams that spend 2 hours debugging is often how well their systems are observed. Observable systems let you ask: “Why was user X’s request slow exactly at 02:47?” and get concrete answers.

Observability is built on three complementary pillars: metrics (numbers changing over time), logs (records of events that happened), and traces (one request’s journey through the system). All three pillars must exist together — none is sufficient alone.

The Three Observability Pillars #

graph TD
    A[Observability] --> B["Metrics\nWhat is happening?"]
    A --> C["Logs\nWhy did it happen?"]
    A --> D["Traces\nWhere did it happen?"]

    B --> B1["Prometheus\nGrafana"]
    C --> C1["Structured Logs\nElasticsearch / Loki"]
    D --> D1["OpenTelemetry\nJaeger / Zipkin"]

    B --> B2["Alerting:\nSomething is wrong"]
    C --> C2["Debugging:\nWhat went wrong"]
    D --> D2["Profiling:\nWhere it went wrong"]
When to use each pillar:

  Metrics — "Something is wrong with the error rate"
  → Aggregate numbers changing over time
  → Ideal for alerting, dashboards, SLO tracking
  → Examples: requests/second, p99 latency, error rates, CPU usage

  Logs — "What actually happened for this failed request?"
  → Structured events with full context
  → Ideal for debugging individual requests/events
  → Example: "User 42 failed to log in because the account is locked"

  Traces — "This request is slow — which service/function is the time spent in?"
  → One request's end-to-end journey through all components
  → Ideal for performance and dependency debugging
  → Example: a span showing 400ms spent in a database query

Metrics: Prometheus and Grafana #

Prometheus is a time-series-based monitoring system. It periodically scrapes metrics from applications and stores them for querying and alerting.

The Four Prometheus Metric Types #

// 1. Counters — only increase, never decrease
// Fits: request counts, error counts, event counts
httpRequestsTotal := prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests",
    },
    []string{"method", "endpoint", "status_code"}, // labels for dimensions
)

// Usage:
httpRequestsTotal.WithLabelValues("GET", "/api/products", "200").Inc()

// 2. Gauges — can go up and down
// Fits: active connections, queue sizes, memory usage
activeConnections := prometheus.NewGauge(prometheus.GaugeOpts{
    Name: "active_connections",
    Help: "Number of active connections",
})

queueSize := prometheus.NewGaugeVec(
    prometheus.GaugeOpts{
        Name: "queue_size",
        Help: "Current number of items in queue",
    },
    []string{"queue_name"},
)

activeConnections.Inc()    // up
activeConnections.Dec()    // down
activeConnections.Set(42)  // set directly

// 3. Histograms — value distributions (for latency)
// Fits: latencies, request/response sizes
requestDurationSeconds := prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request duration",
        Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
        // buckets = time boundaries in seconds (5ms, 10ms, 25ms, ...)
    },
    []string{"method", "endpoint"},
)

// Usage — distributions calculated automatically
timer := prometheus.NewTimer(
    requestDurationSeconds.WithLabelValues("GET", "/api/products"),
)
result := fetchProducts()
timer.ObserveDuration()

// 4. Summaries — like Histograms but quantiles calculated client-side
// Rarely used because they can't be aggregated across instances
requestProcessingSeconds := prometheus.NewSummary(prometheus.SummaryOpts{
    Name: "request_processing_seconds",
    Help: "Time spent processing request",
})

Instrumenting Flask Applications #

// Go: net/http middleware equivalent
// Metrics
var (
    requestCount = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "flask_request_count",
            Help: "Flask Request Count",
        },
        []string{"method", "endpoint", "status"},
    )

    requestLatency = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "flask_request_latency_seconds",
            Help:    "Flask Request Latency",
            Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0},
        },
        []string{"method", "endpoint"},
    )
)

// statusRecorder captures the response status code
type statusRecorder struct {
    http.ResponseWriter
    status int
}

// Middleware — runs before and after every request
func metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(rec, r)

        requestCount.WithLabelValues(
            r.Method, r.URL.Path, strconv.Itoa(rec.status),
        ).Inc()
        requestLatency.WithLabelValues(r.Method, r.URL.Path).
            Observe(time.Since(start).Seconds())
    })
}

// The endpoint for Prometheus scraping
func metricsHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    promhttp.Handler().ServeHTTP(w, r)
}
# prometheus.yml — scraping configuration

global:
  scrape_interval: 15s      # scrape every 15 seconds
  evaluation_interval: 15s  # evaluate alerts every 15 seconds

scrape_configs:
  - job_name: 'myapp'
    static_configs:
      - targets: ['app:8000']  # host:port of the /metrics endpoint
    metrics_path: '/metrics'

  - job_name: 'node-exporter'  # system metrics (CPU, memory, disk)
    static_configs:
      - targets: ['node-exporter:9100']

Logs: Structured Logging #

Unstructured logs are hard to query and parse. Structured logging uses JSON so every field can be filtered and aggregated.

// Go: standard library log/slog equivalent
// requestID carries the request ID through the request context
type ctxKey string

const requestIDKey ctxKey = "request_id"

// structuredFormatter emits one JSON object per log line
func structuredFormatter(r slog.Record) ([]byte, error) {
    attrs := make(map[string]any)
    attrs["timestamp"] = r.Time.Format("2006-01-02T15:04:05.000")
    attrs["level"] = r.Level.String()
    attrs["logger"] = "myapp"
    attrs["message"] = r.Message
    attrs["request_id"] = ""
    attrs["service"] = "myapp"
    attrs["environment"] = os.Getenv("APP_ENV")

    r.Attrs(func(a slog.Attr) bool {
        attrs[a.Key] = a.Value.Any()
        return true
    })

    return json.Marshal(attrs)
}

// Middleware for injecting request IDs
func injectRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = uuid.New().String()
        }
        // Also set it in the response header for client tracing
        w.Header().Set("X-Request-ID", requestID)
        next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, requestID)))
    })
}

// logRequest logs a structured line after every request
func logRequest(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)

        logger.Info("HTTP request completed",
            "method", r.Method,
            "path", r.URL.Path,
            "duration_ms", float64(time.Since(start).Microseconds())/1000,
            "request_id", r.Context().Value(requestIDKey),
        )
    })
}
Structured vs unstructured log examples:

  Unstructured (hard to parse):
  [2025-06-01 14:23:45] ERROR User 42 failed to log in from IP 1.2.3.4 after 3 attempts

  Structured (easy to filter and aggregate):
  {
    "timestamp": "2025-06-01T14:23:45.123",
    "level": "ERROR",
    "message": "Login failed",
    "user_id": 42,
    "ip": "1.2.3.4",
    "attempt_count": 3,
    "reason": "account_locked",
    "request_id": "req-abc123",
    "service": "auth-service"
  }

  With structured logs, Elasticsearch queries like:
  level:ERROR AND reason:account_locked AND @timestamp:[now-1h TO now]

  In Loki with LogQL:
  {service="auth-service"} | json | reason="account_locked"

Distributed Tracing with OpenTelemetry #

Distributed tracing connects logs and metrics from various services into one narrative about a request’s journey.

// Go: OpenTelemetry Go SDK equivalent
func setupTracing(serviceName string, otlpEndpoint string) {
    // serviceName and otlpEndpoint identify this service and the collector
    ctx := context.Background()

    exporter, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint(otlpEndpoint),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        log.Fatal(err)
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName(serviceName),
            attribute.String("service.version", os.Getenv("APP_VERSION")),
            attribute.String("deployment.environment", os.Getenv("APP_ENV")),
        )),
    )
    otel.SetTracerProvider(tp)
}

// Tracer for manual instrumentation
var tracer = otel.Tracer("myapp")

// Manual span examples
func processOrder(ctx context.Context, orderData map[string]any) (map[string]any, error) {
    ctx, span := tracer.Start(ctx, "process_order")
    defer span.End()
    // Add useful attributes for debugging
    span.SetAttributes(
        attribute.String("order.id", orderData["id"].(string)),
        attribute.Float64("order.total", orderData["total"].(float64)),
        attribute.Int("order.item_count", len(orderData["items"].([]any))),
    )

    ctx, inventorySpan := tracer.Start(ctx, "validate_inventory")
    inventoryOK := checkInventory(orderData["items"])
    if !inventoryOK {
        inventorySpan.RecordError(errors.New("Insufficient inventory"))
        inventorySpan.SetStatus(codes.Error, "Insufficient inventory")
        inventorySpan.End()
        return nil, errors.New("insufficient inventory")
    }
    inventorySpan.End()

    _, paymentSpan := tracer.Start(ctx, "process_payment")
    paymentSpan.SetAttributes(attribute.Float64("payment.amount", orderData["total"].(float64)))
    result := chargePayment(orderData)
    paymentSpan.SetAttributes(attribute.String("payment.transaction_id", result["transaction_id"].(string)))
    paymentSpan.End()

    return finalizeOrder(orderData, result)
}

SLI, SLO, and SLA: The Reliability Language #

Observability isn’t only technical — it’s also about defining and measuring reliability from the user’s perspective.

Definitions:

  SLI (Service Level Indicator):
  → Concrete metrics measuring reliability aspects
  → Measurable numbers: availability, latency, error rates, throughput
  → Example: "The percentage of HTTP requests completing < 200ms"

  SLO (Service Level Objective):
  → Target values for SLIs agreed upon internally
  → Usually expressed as percentages over time periods
  → Example: "99.9% of requests must complete < 200ms within 30 days"

  SLA (Service Level Agreement):
  → Formal contracts with consequences if SLOs aren't met
  → Usually between providers and customers
  → Example: "If availability < 99.9%, customers get credits"

  Error Budgets:
  → The amount of "allowed failure" before violating SLOs
  → A 99.9% SLO over 30 days = 0.1% × 30 × 24 × 60 = 43.2 minutes of downtime
  → Used for balance: reliability vs development speed
// Go: calculating and tracking SLI/SLO
// SLI 1: Availability (percentage of successful requests)
httpRequestsTotal := prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests",
    },
    []string{"status_code"},
)

// Availability = successful requests / total requests
// Prometheus query:
// sum(rate(http_requests_total{status_code!~"5.."}[30d]))
// /
// sum(rate(http_requests_total[30d]))
func calculateAvailabilitySLI() float64 {
    // Implemented via the Prometheus API
    return 0.0
}

// SLI 2: Latency (percentage of requests under thresholds)
requestDuration := prometheus.NewHistogram(prometheus.HistogramOpts{
    Name:    "http_request_duration_seconds",
    Help:    "Request duration",
    Buckets: []float64{0.1, 0.2, 0.5, 1.0, 2.0, 5.0},
})

// Latency SLI query:
// sum(rate(http_request_duration_seconds_bucket{le="0.2"}[30d]))
// /
// sum(rate(http_request_duration_seconds_count[30d]))
// → The percentage of requests completing within 200ms

// Alerts for SLO breaches and error budget burn rates
# Prometheus alerting rules for SLOs

groups:
  - name: slo_alerts
    rules:
      # Alert if error rates exceed thresholds
      - alert: HighErrorRate
        expr: |
          sum(rate(http_requests_total{status_code=~"5.."}[5m]))
          /
          sum(rate(http_requests_total[5m])) > 0.05          
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Error rate > 5% for 5 minutes"
          description: "Error rate: {{ $value | humanizePercentage }}"

      # Alerts for latency SLOs
      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
          ) > 1.0          
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "P99 latency > 1 second"
          description: "P99 latency: {{ $value }}s"

      # Alerts for error budget burn rates too fast
      - alert: ErrorBudgetBurnRateFast
        expr: |
          (
            sum(rate(http_requests_total{status_code=~"5.."}[1h]))
            /
            sum(rate(http_requests_total[1h]))
          ) > 0.001 * 14.4
          # 14.4x the normal burn rate = exhausted in 5 days          
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Error budget burn rate too fast"

Effective Alerting #

Good alerts make engineers take action. Bad alerts create alert fatigue — many notifications eventually ignored.

Effective alerting principles:

  Alerts only for things needing HUMAN action NOW
  ✗ Alerts not needing immediate action → notifications, not alerts
  ✗ Alerts always appearing but never critical → remove them
  ✓ Meaningful alerts: something is wrong and needs attention now

  Alert on symptoms, not causes
  ✗ "CPU 90%" — may be fine if latency is still good
  ✓ "P99 latency > 2 seconds" — this is what users feel
  ✗ "Disk 80% full" — not necessarily a problem now
  ✓ "Disk will be full in 4 hours at the current rate"

  Clear severities
  P1/Critical: Needs IMMEDIATE action, wake up on-call
    → Services down, data corruption, security breaches
  P2/Warning: Needs action within this hour
    → Degraded performance, approaching limits
  P3/Info: Worth knowing, not urgent
    → Scheduled tasks completed, config changes

  Every alert must have a runbook
  → Links to documentation: what to do
  → Context: why this alert exists
  → Escalation: who to contact if unresolved
# Alertmanager routing — send alerts to the right channels

global:
  resolve_timeout: 5m

route:
  group_by: ['alertname', 'cluster']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'slack-default'

  routes:
    # Critical alerts → PagerDuty (wake up on-call)
    - match:
        severity: critical
      receiver: 'pagerduty-oncall'
      continue: false

    # Warning alerts → Slack
    - match:
        severity: warning
      receiver: 'slack-alerts'

receivers:
  - name: 'pagerduty-oncall'
    pagerduty_configs:
      - service_key: ${PAGERDUTY_KEY}
        description: '{{ range .Alerts }}{{ .Annotations.summary }}{{ end }}'

  - name: 'slack-alerts'
    slack_configs:
      - api_url: ${SLACK_WEBHOOK}
        channel: '#alerts'
        text: |
          *Alert:* {{ .GroupLabels.alertname }}
          *Summary:* {{ range .Alerts }}{{ .Annotations.summary }}{{ end }}
          *Runbook:* {{ range .Alerts }}{{ .Annotations.runbook_url }}{{ end }}          

Grafana Dashboards: Useful Visualizations #

Dashboards every service should have:

  Overview Dashboards (for on-call):
  → Request rates (requests per second)
  → Error rates (%)
  → P50, P95, P99 latencies
  → Active instances / uptime

  Resource Dashboards:
  → CPU usage per instance
  → Memory usage (RSS, available)
  → Disk I/O and utilization
  → Network throughput

  Business Metrics Dashboards:
  → Transactions per minute/hour
  → Revenue (if relevant)
  → Conversion rates
  → Active users

  Dependency Dashboards:
  → Database query latencies
  → Cache hit rates
  → External API latencies and error rates
  → Circuit breaker states

  Tips for useful dashboards:
  ✓ Order panels from most important (top-left = most critical)
  ✓ Always show context: current graphs vs yesterday vs last week
  ✓ Use consistent colors: red = bad, green = good
  ✓ Add annotations for deployments and incidents
  ✓ Make dashboards filterable per environment and instance

Anti-Patterns to Avoid #

// Go: anti-patterns
// ✗ Anti-pattern 1: logs as the only observability
// No metrics, no tracing
// Debugging requires manual greps through thousands of log lines
// ✓ Solution: implement all three pillars — metrics, logs, traces

// ✗ Anti-pattern 2: unstructured logs
logger.Printf("User %d failed to login from %s after %d attempts", userID, ip, n)
// Can't be queried, aggregated, or filtered
// ✓ Solution: structured JSON logs with consistent fields

// ✗ Anti-pattern 3: alerting on non-actionable metrics
// alert: CPU > 80%
// What should be done? Who should be contacted?
// ✓ Solution: alert on symptoms (latency, error rates) not causes

// ✗ Anti-pattern 4: alert fatigue — too many alerts
// Teams get 200 alerts a day, all ignored
// ✓ Solution: audit alerts regularly, remove ones never actionable

// ✗ Anti-pattern 5: no runbooks for alerts
// Alerts go off, on-call doesn't know what to do
// ✓ Solution: every alert has a clear runbook link

// ✗ Anti-pattern 6: trace IDs not propagated
// Traces start at API gateways but aren't forwarded to other services
// Traces fragment, can't be viewed end-to-end
// ✓ Solution: OpenTelemetry auto-instrumentation propagating trace contexts

// ✗ Anti-pattern 7: dashboards with too many graphs
// 50+ panels on one dashboard, hard to focus during incidents
// ✓ Solution: concise dashboards for on-call, detailed dashboards for debugging

Observability Checklist #

METRICS:
  □ The four golden signals monitored: latency, traffic, errors, saturation
  □ Request rates, error rates, and latencies (P50/P95/P99) exist in every service
  □ Critical business metrics monitored (transactions, conversion rates)
  □ Dependency metrics: database query times, cache hit rates, external API latencies
  □ Resource metrics: CPU, memory, disk, network

LOGGING:
  □ Structured logging (JSON) in all services
  □ Consistent log levels (DEBUG, INFO, WARN, ERROR, FATAL)
  □ Request IDs/Correlation IDs in every log entry
  □ No sensitive data (passwords, tokens, PII) in logs
  □ Logs aggregated to central systems (ELK, Loki, CloudWatch)

TRACING:
  □ OpenTelemetry configured in all services
  □ Trace IDs propagated to all downstream calls
  □ Meaningful spans with useful attributes
  □ Traces sent to backends (Jaeger, Zipkin, or managed services)

SLO:
  □ SLIs defined for every service (availability, latency, error rates)
  □ SLO targets agreed with stakeholders
  □ Error budgets calculated and tracked
  □ Alerts for error budget burn rates too fast

ALERTING:
  □ Alerts only for conditions needing human action
  □ Clear severities (critical vs warning)
  □ Every alert has a runbook
  □ Correct alert routing (critical → on-call, warning → Slack)
  □ Alerts reviewed regularly to remove non-actionable ones

DASHBOARDS:
  □ Overview dashboards for on-call (concise, most important info)
  □ Detail dashboards for debugging
  □ Dashboards filterable per environment
  □ Annotations for deployments and incidents

Summary #

  • Observability isn’t monitoring — monitoring tells you when something breaks. Observability lets you answer any question about systems from their output, even questions never thought of before.
  • Three complementary pillars — metrics for alerting and trends, logs for debugging specific events, traces for tracing requests end-to-end. None is sufficient alone.
  • Four Golden Signals — latency, traffic, errors, and saturation are the minimum to monitor for every service. These correlate most directly with user experience.
  • Structured logging is an investment — queryable-per-field JSON logs are far more useful than free-text logs during incidents. Implement early, not as a refactor when problems already exist.
  • Distributed tracing depends on trace context propagation — OpenTelemetry must be configured in all services and trace IDs forwarded to every downstream call. Fragmented traces are useless.
  • SLOs define real reliability targets — without SLOs, nobody knows how reliable systems “should be”. Error budgets enable balance between reliability and delivery speed.
  • Alert on symptoms, not causes — P99 latency > 2 seconds is more actionable than CPU > 80%. Users feel latency, not CPU usage.
  • Alert fatigue kills on-call — 200 ignored alerts per day are more dangerous than no alerts at all. Audit alerts regularly and remove ones never actionable.
  • Every alert needs a runbook — on-call receiving alerts at midnight must know exactly what to do, who to contact, and how to escalate.
  • Dashboards designed for usage scenarios — on-call dashboards (concise, most important) differ from debugging dashboards (detailed). Don’t make one dashboard for everything.
#

← Previous: Circuit Breaker   Next: Echo Chamber

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact