Event Sourcing Observability: Prometheus, Grafana, Jaeger

Part 12 in the “Building Event-Driven Microservices with Hazelcast” series


Over the past eleven posts, we’ve built an event sourcing framework, a Jet pipeline, materialized views, sagas, circuit breakers, an outbox, dead letter queues, and durable persistence. That’s a lot of moving parts.

Now: how do you observe what’s happening inside all of them?

Event sourcing changes the observability game. Traditional request-response applications have easy metrics — request rate, error rate, latency. In an event-sourced system, a single API call triggers an asynchronous pipeline that writes to an event store, updates a materialized view, publishes to subscribers, and potentially kicks off a multi-service saga. A latency spike could be hiding in any of those stages. You need to see into all of them.

This post builds a complete observability stack: Prometheus + Micrometer for metrics, Grafana for dashboards and alerting, Jaeger for distributed tracing.


How It Fits Together

Observability stack architecture: four Spring Boot services on ports 8081 through 8084 expose /actuator/prometheus endpoints that Prometheus scrapes every 15 seconds on port 9090, and export OTLP spans to Jaeger on port 16686; Grafana on port 3000 reads from Prometheus and renders five auto-provisioned dashboards with six alerts

Each service exposes /actuator/prometheus. Prometheus scrapes all four every 15 seconds. Grafana reads from Prometheus and renders dashboards. Jaeger collects distributed traces via OTLP.


Instrumenting the Framework

Metrics Architecture

An event-sourced system has a lot of moving parts, and each one needs its own instrumentation. The framework provides roughly 70 metrics across a dozen categories. They’re organized around two core subsystems — the event pipeline and the saga layer — plus several supporting categories for everything else.

Pipeline Metrics

PipelineMetrics tracks every event through the 6-stage pipeline:

public class PipelineMetrics {

    private final MeterRegistry registry;
    private final String domainName;

    // Events entering and leaving the pipeline
    Counter eventsReceived;   // "eventsourcing.pipeline.events.received"
    Counter eventsProcessed;  // "eventsourcing.pipeline.events.processed"
    Counter eventsFailed;     // "eventsourcing.pipeline.events.failed"

    // End-to-end latency histogram with percentiles
    Timer endToEndLatency;    // "eventsourcing.pipeline.latency.end_to_end"
    Timer queueWaitLatency;   // "eventsourcing.pipeline.latency.queue_wait"

    // Per-stage timing
    Timer stageDuration;      // "eventsourcing.pipeline.stage.duration"
                              // Tagged with stage: persist, update_view, publish
}

Every metric is tagged with domain (e.g., “Customer”, “Order”) and eventType (e.g., “CustomerCreated”), so you can filter as narrowly as you need:

Counter.builder("eventsourcing.pipeline.events.processed")
    .tag("domain", domainName)
    .tag("eventType", eventType)
    .register(registry);

The per-stage timer is the one I find most useful for debugging. If P99 spikes, you can see which stage is the bottleneck — is it the event store write, the view update, or the publication step?

public enum PipelineStage {
    SOURCE("source"),
    ENRICH("enrich"),
    PERSIST("persist"),
    UPDATE_VIEW("update_view"),
    PUBLISH("publish"),
    COMPLETE("complete")
}

public void recordStageTiming(PipelineStage stage, Instant start) {
    Timer.builder("eventsourcing.pipeline.stage.duration")
        .tag("domain", domainName)
        .tag("stage", stage.getLabel())
        .publishPercentiles(0.5, 0.95, 0.99)
        .register(registry)
        .record(Duration.between(start, Instant.now()));
}

Saga Metrics

SagaMetrics tracks the lifecycle of distributed sagas:

public class SagaMetrics {

    private static final String PREFIX = "saga";

    // Lifecycle counters (tagged by sagaType)
    "saga.started"               // Sagas initiated
    "saga.completed"             // Sagas completed successfully
    "saga.compensated"           // Sagas that required compensation
    "saga.failed"                // Sagas that failed
    "saga.timedout"              // Sagas that exceeded their deadline

    // Step-level counters
    "saga.steps.completed"       // Individual steps completed
    "saga.steps.failed"          // Individual steps failed
    "saga.compensation.started"  // Compensation processes initiated
    "saga.compensation.steps"    // Compensation steps executed

    // Duration timers (p50, p95, p99)
    "saga.duration"              // End-to-end saga duration
    "saga.compensation.duration" // Compensation duration
}

Beyond the Core

Pipeline and saga metrics tell you how events flow and how transactions coordinate. But a production system has more to watch. The framework instruments several additional subsystems:

The outbox pattern guarantees at-least-once delivery to the shared cluster. Metrics track entries written, claimed, delivered, and failed. If outbox.entries.written is climbing faster than outbox.entries.delivered, your delivery pipeline is falling behind.

Events that fail delivery repeatedly land in the DLQ. Three counters — dlq.entries.added, dlq.entries.replayed, dlq.entries.discarded — tell you whether poison messages are accumulating or getting resolved.

When PostgreSQL persistence is enabled, write/read latency, batch sizes, and error counts are tracked per map. High persistence.store.duration points to database bottlenecks.

The idempotency guard tracks duplicate detection tagged hit (duplicate blocked) or miss (new event). A high hit ratio under normal operation is actually good news — it means at-least-once delivery is working and duplicates are being caught.

Circuit breaker state, failure rates, and retry outcomes from Resilience4j are exposed automatically. When resilience4j_circuitbreaker_state flips to OPEN, a downstream service is in trouble.

And then there are business metrics — revenue, order item counts, customer totals, inventory replenishment. These are the ones business stakeholders actually care about. They bridge the gap between “pipeline is fast” and “orders are generating revenue.”

The complete catalog of every metric name, tag, Prometheus mapping, and troubleshooting guide is in the Metrics Reference Guide.

Caching Metric Instances

At 100,000+ events per second, looking up a Counter in Micrometer’s registry on every event is measurable overhead. SagaMetrics caches metric instances in a ConcurrentHashMap:

private final ConcurrentMap<String, Counter> counterCache;
private final ConcurrentMap<String, Timer> timerCache;

private Counter getCounter(String name, String sagaType) {
    String key = name + ":" + sagaType;
    return counterCache.computeIfAbsent(key, k ->
            Counter.builder(PREFIX + "." + name)
                    .tag("sagaType", sagaType)
                    .register(meterRegistry)
    );
}

Small detail, but at high throughput every nanosecond in the hot path matters.


JVM and System Metrics

Beyond application metrics, the framework auto-registers JVM metrics via MetricsConfig:

@Configuration
public class MetricsConfig {

    @Bean public JvmMemoryMetrics jvmMemoryMetrics()   { return new JvmMemoryMetrics(); }
    @Bean public JvmGcMetrics jvmGcMetrics()           { return new JvmGcMetrics(); }
    @Bean public JvmThreadMetrics jvmThreadMetrics()    { return new JvmThreadMetrics(); }
    @Bean public ClassLoaderMetrics classLoaderMetrics() { return new ClassLoaderMetrics(); }
    @Bean public ProcessorMetrics processorMetrics()    { return new ProcessorMetrics(); }
}

Common tags applied to every metric enable cross-service filtering:

registry.config().commonTags(Arrays.asList(
    Tag.of("application", applicationName),
    Tag.of("version", applicationVersion)
));

Every metric — JVM heap usage, saga completion rates, business revenue — can be filtered by service name in Grafana.


Configuring Prometheus

Service-Side: Spring Boot Actuator

Each service exposes metrics via Actuator with Prometheus export:

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,prometheus,metrics,info
  endpoint:
    health:
      show-details: always
  metrics:
    export:
      prometheus:
        enabled: true
    tags:
      application: ${spring.application.name}

This creates the /actuator/prometheus endpoint that Prometheus scrapes.

Prometheus-Side: Scrape Configuration

Prometheus scrapes all four services and the Hazelcast cluster:

# docker/prometheus/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s
  external_labels:
    monitor: 'ecommerce-demo'

scrape_configs:
  - job_name: 'account-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['account-service:8081']

  - job_name: 'inventory-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['inventory-service:8082']

  - job_name: 'order-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['order-service:8083']

  - job_name: 'payment-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['payment-service:8084']

  - job_name: 'hazelcast'
    metrics_path: '/hazelcast/rest/cluster'
    static_configs:
      - targets:
          - 'hazelcast-1:5701'
          - 'hazelcast-2:5701'
          - 'hazelcast-3:5701'

Each service gets its own job, so Prometheus labels metrics with job=”account-service” etc. automatically.


Grafana Dashboards

Dashboard Strategy

Five pre-provisioned dashboards, each focused on a different operational concern:

Dashboard Focus Key Question It Answers
System Overview Service health and throughput “Is everything running? How much traffic?”
Event Flow Pipeline performance “How fast are events processing? Where are bottlenecks?”
Materialized Views View update performance “Are views keeping up with events?”
Saga Dashboard Distributed transaction health “Are sagas completing? Any failures or timeouts?”
Business Overview Revenue, orders, customers “Is the business healthy? Are orders generating revenue?”

System Overview is the home dashboard — first thing you see when you open Grafana.

Auto-Provisioning

Dashboards, datasources, and alerts are all provisioned automatically. When Grafana starts, it reads configuration from mounted volumes:

docker/grafana/
├── dashboards/                    # Dashboard JSON files
│   ├── system-overview.json       # Auto-loaded as home dashboard
│   ├── event-flow.json
│   ├── materialized-views.json
│   ├── saga-dashboard.json
│   └── business-overview.json
└── provisioning/
    ├── datasources/
    │   └── datasources.yml        # Points to Prometheus
    ├── dashboards/
    │   └── dashboards.yml         # Tells Grafana where to find JSONs
    └── alerting/
        ├── alerts.yml             # Alert rule definitions
        ├── contactpoints.yml      # Notification channels
        └── policies.yml           # Routing policies

No manual setup. docker-compose up and the dashboards are ready.

Key Dashboard Panels

System Overview

At-a-glance health for the whole system: service health indicators (green/red per service based on up{job=”…”}), event throughput by service, HTTP request rates, pipeline P95 latency, and a saga summary showing started, completed, failed, and timed out counts.

Saga Dashboard

Deep visibility into distributed transactions. Active saga count and compensating count — how many are in flight right now. Throughput charts for start, complete, and compensate rates, filterable by sagaType. Duration percentiles at P50, P95, P99. Success rate as a percentage. Timeout detection rate. Compensation breakdown — are any compensation steps failing?

The dashboard supports a $sagaType variable, so you can filter to just “OrderFulfillment” or “OrderFulfillmentOrchestrated” or view everything at once.

Event Flow

The pipeline performance dashboard: events published per second by service, end-to-end latency percentiles, queue wait latency (are events sitting around before processing starts?), a stacked stage duration breakdown at P95 for persist, update_view, and publish, and failed events by stage and type.

Business Overview

This one bridges technical and business concerns. Cumulative revenue over time, order rate with item counts, customer growth, saga success rate (what percentage of orders complete without compensation?), and end-to-end saga duration.


Alerting

Pre-Configured Alerts

Six alerts that cover the most common failure modes:

Saga Alerts

Alert Severity Condition For
High Saga Failure Rate Critical increase(saga_failed_total[5m]) > 0 2 min
Saga Timeouts Detected Warning increase(saga_timeouts_detected_total[5m]) > 0 2 min
Saga Compensation Failures Critical increase(saga_compensations_failed_total[5m]) > 0 1 min
Low Saga Success Rate Warning Success rate < 90% over 10 minutes 5 min

Service Health Alerts

Alert Severity Condition For
Service Down Critical up < 1 for any service 1 min
High Event Processing Error Rate Warning Error rate > 5% over 5 minutes 3 min

The “For” duration prevents flapping — a brief network blip won’t page you at 3am. Compensation failures fire fastest (1 minute) because a failed compensation means money or inventory is in an inconsistent state.


Distributed Tracing with Jaeger

Metrics tell you that something is slow. Tracing tells you why.

In our system, a single order placement can touch four services: Order creates the order, Inventory reserves stock, Payment processes the charge, Order confirms. With metrics alone, you see “P99 saga duration increased.” With tracing, you see “Payment Service is taking 2 seconds to respond to StockReserved events.” That’s the difference between knowing there’s a problem and knowing where it is.

Configuration

Tracing is enabled via Spring Boot’s OpenTelemetry integration:

management:
  tracing:
    enabled: true
    sampling:
      probability: 1.0   # Sample 100% of requests (reduce in production)
  otlp:
    tracing:
      endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}

Jaeger runs as an all-in-one container in the Docker stack, receiving traces via OTLP on port 4317. In the Jaeger UI (http://localhost:16686), you pick a service, find traces for a time window, click into a trace to see the span waterfall across all four services, and identify which operation is contributing to latency.

The most valuable traces in an event-sourced system: the full path from API request to pipeline completion, saga flows (OrderCreated → StockReserved → PaymentProcessed → OrderConfirmed), and compensation flows — where did the failure occur, and how long did compensation take?


Useful PromQL Queries

Queries you can run in Prometheus or use in custom Grafana panels:

Service Health

# Are all services up?
up{job=~".*-service"}

# HTTP request rate by service
sum by (application) (rate(http_server_requests_seconds_count[5m]))

Event Pipeline

# Event throughput
rate(eventsourcing_pipeline_events_processed_total[5m])

# End-to-end P99 latency
histogram_quantile(0.99, rate(eventsourcing_pipeline_latency_end_to_end_seconds_bucket[5m]))

# Queue wait time (events waiting to be processed)
histogram_quantile(0.95, rate(eventsourcing_pipeline_latency_queue_wait_seconds_bucket[5m]))

Sagas

# Saga completion rate
rate(saga_completed_total[5m])

# Saga success rate (percentage)
sum(saga_completed_total) /
  (sum(saga_completed_total) + sum(saga_compensated_total) +
   sum(saga_failed_total) + sum(saga_timedout_total))

# Saga duration P99
histogram_quantile(0.99, rate(saga_duration_seconds_bucket[5m]))

# Active timeouts in last 5 minutes
increase(saga_timeouts_detected_total[5m])

Business

# Cumulative revenue
order_revenue_total

# Orders per second
rate(order_items_count_total[5m])

# Current customer count
account_customers_total

JVM

# Heap memory usage
jvm_memory_used_bytes{area="heap"}

# GC pause time
rate(jvm_gc_pause_seconds_sum[5m])

Lessons Learned

Don’t just measure HTTP latency. In an event-sourced system, the interesting latency is inside the pipeline — from event submission to view update. HTTP latency includes that but hides where the time is spent.

Multi-dimensional tags (domain, eventType, sagaType, stage) are not optional. A P99 spike in “pipeline latency” is useless without knowing which domain and stage are affected.

Cache your Counter and Timer instances at high throughput. Registry lookups add up. ConcurrentHashMap.computeIfAbsent works well.

Provision everything as code. Don’t create dashboards by hand — provision them from JSON files. Your observability stack is version-controlled, reproducible, and deploys automatically. When someone clones the repo and runs docker-compose up, they get the same dashboards as everyone else.

Alert on business outcomes, not just infrastructure. “Service Down” is an infrastructure alert. “Saga Failure Rate” is a business outcome alert. Both matter, but the business alerts catch problems that don’t manifest as service crashes — like a payment gateway returning errors, causing saga compensations to spike while all four services stay green.


The framework provides roughly 70 metrics organized into a dozen categories — pipeline throughput and per-stage latency, saga lifecycle tracking, outbox delivery, dead letter queues, persistence latency, circuit breaker state, business KPIs, JVM health, HTTP request rates. Combined with auto-provisioned Grafana dashboards, pre-configured alerts, and distributed tracing via Jaeger, you get complete visibility into a system where a single API call can trigger asynchronous processing across four services.

Event sourcing makes observability both harder and more important. Events are asynchronous, distributed, and flow through multiple stages. Without good metrics and dashboards, you’re flying blind. The Metrics Reference Guide has the complete catalog.


Previous: Hazelcast Write-Behind MapStore: Durable Event Sourcing

Code: github.com/myawnhc/hazelcast-microservices-framework — clone it, docker-compose up, and the framework boots locally with sample data.

Comments

Leave a comment