# Guide 2: Prometheus + Grafana Monitoring Stack ## What Are Prometheus and Grafana? **Prometheus** is a time-series database designed for monitoring. Instead of your application pushing data to Prometheus, Prometheus **pulls** (or "scrapes") data from your application on a schedule — typically every 15 seconds. Your application exposes a `/metrics` HTTP endpoint with the current values of all metrics, and Prometheus stores a timestamped history of those values. A **time-series** is just a series of numbers recorded over time — like "at 14:00 there were 3 pending events, at 14:15 there were 5, at 14:30 there were 0." Prometheus stores millions of these series efficiently and lets you query them with its built-in query language, PromQL. **Grafana** is a visualization tool that connects to Prometheus (and other data sources) and renders interactive dashboards — line graphs, gauges, stat panels, and alerts. Prometheus stores the numbers; Grafana makes them visual. **Why use them together?** Logging tells you _what happened_ ("observation ingested for encounter X"). Metrics tell you _how the system is performing right now_ ("we're ingesting 50 observations per second and the p99 latency is 23ms"). When something goes wrong, metrics tell you instantly — often before anyone notices a problem. --- ## Why Prometheus and Grafana in This Project? VigilCareClinical is a patient safety system. A growing outbox means delayed alerts. High Kafka consumer lag means scores aren't being computed. An unacknowledged critical alert means a patient needs attention. Prometheus collects these metrics every 15 seconds, and Grafana visualizes them on dashboards that clinicians and engineers can watch in real time. --- ## Architecture Overview ``` .NET API (host) Docker ┌──────────────────┐ scrape ┌─────────────┐ query ┌─────────────┐ │ /metrics │ ◄──────────── │ Prometheus │ ◄───────── │ Grafana │ │ (prometheus-net)│ every 15s │ :9101 │ │ :3101 │ └──────────────────┘ └─────────────┘ └─────────────┘ ▲ │ expose ┌───────┴──────────┐ │ ClinicalMetrics │ (singleton) │ 4 Collectors │ (BackgroundServices) └──────────────────┘ ``` 1. **prometheus-net** (a .NET library) exposes a `/metrics` endpoint on the API that outputs all metric values in Prometheus's text format 2. **Prometheus** (running in Docker) scrapes that endpoint every 15 seconds and stores the values with timestamps 3. **Grafana** (also in Docker) queries Prometheus and renders the data as charts and dashboards --- ## Step 1: The NuGet Package ```xml ``` This package provides: - The `Metrics` factory for creating counters, histograms, and gauges - The `MapMetrics()` extension method to expose the `/metrics` HTTP endpoint - ASP.NET Core middleware integration --- ## Step 2: Registering the Metrics Endpoint In `Program.cs`: ```csharp // Register ClinicalMetrics as a singleton so all services share the same instances builder.Services.AddSingleton(); // ... later, after building the app: // Expose /metrics for Prometheus to scrape app.MapMetrics("/metrics"); ``` That single `MapMetrics("/metrics")` call serves the Prometheus text exposition format at `http://localhost:5270/metrics`. --- ## Step 3: Defining Custom Metrics — ClinicalMetrics All custom metrics are defined in a single class, registered as a singleton. This ensures every service that injects `ClinicalMetrics` increments the same counter instances. ### Counters Prometheus has three core metric types. The first is a **counter**. A counter only goes up — it never decreases. Think of it like the odometer on a car: it tracks the total distance driven since the car was built. A counter tracks things like "total observations ingested since the app started." The raw number isn't that useful on its own (who cares that we've ingested 50,000 total?), but Prometheus's `rate()` function converts it to "observations per second over the last minute" — that's actionable. ```csharp public sealed class ClinicalMetrics { // Labeled by observation_code and source public readonly Counter ObservationsIngestedTotal = Metrics.CreateCounter( "observations_ingested_total", "Total observations ingested, labeled by observation code and source.", labelNames: new[] { "observation_code", "source" }); // Labeled by alert_type and severity public readonly Counter ClinicalAlertsTotal = Metrics.CreateCounter( "clinical_alerts_total", "Total clinical alerts generated, labeled by type and severity.", labelNames: new[] { "alert_type", "severity" }); public readonly Counter News2ScoresTotal = Metrics.CreateCounter( "news2_scores_total", "Total NEWS2 scores computed, labeled by risk level.", labelNames: new[] { "risk_level" }); public readonly Counter EscalationsTotal = Metrics.CreateCounter( "escalations_total", "Total alert escalations processed through the DLQ escalation path."); public readonly Counter TrendAlertsTotal = Metrics.CreateCounter( "trend_alerts_total", "Total RAPID_DETERIORATION alerts generated.", labelNames: new[] { "observation_code" }); public readonly Counter AlertSuppressionsTotal = Metrics.CreateCounter( "alert_suppressions_total", "Total alert suppression windows set after acknowledgment.", labelNames: new[] { "alert_type" }); public readonly Counter QsofaDetectionsTotal = Metrics.CreateCounter( "qsofa_detections_total", "Total QSOFA_SCREEN alerts generated."); public readonly Counter SepsisBundleComplianceTotal = Metrics.CreateCounter( "sepsis_bundle_compliance_total", "Sepsis bundle compliance outcomes.", labelNames: new[] { "status" }); public readonly Counter GcsScoresTotal = Metrics.CreateCounter( "gcs_scores_total", "GCS scores computed, labeled by classification.", labelNames: new[] { "classification" }); public readonly Counter SofaScoresTotal = Metrics.CreateCounter( "sofa_scores_total", "SOFA scores computed.", labelNames: new[] { "has_delta_alert" }); public readonly Counter FhirIngestTotal = Metrics.CreateCounter( "fhir_ingest_total", "FHIR resource ingest operations.", labelNames: new[] { "resource_type", "outcome" }); public readonly Counter FhirReadTotal = Metrics.CreateCounter( "fhir_read_total", "FHIR resource read/search operations.", labelNames: new[] { "resource_type", "interaction", "outcome" }); public readonly Counter AuthorizationFailuresTotal = Metrics.CreateCounter( "authorization_failures_total", "Authorization failures by permission and role.", labelNames: new[] { "permission", "role" }); public readonly Counter FhirMappingErrorsTotal = Metrics.CreateCounter( "fhir_mapping_errors_total", "FHIR mapping failures.", labelNames: new[] { "reason" }); public readonly Counter PhiAccessLogsTotal = Metrics.CreateCounter( "phi_access_logs_total", "PHI access log entries written.", labelNames: new[] { "access_type" }); public readonly Counter ClinicalSyncBatchesTotal = Metrics.CreateCounter( "clinical_sync_batches_total", "Sync batches processed.", labelNames: new[] { "status" }); } ``` **What are labels?** Labels are key-value tags attached to a metric that let you slice and filter the data. Instead of creating separate counters like `critical_heart_rate_alerts_total` and `warning_spo2_alerts_total`, you create one counter `clinical_alerts_total` with labels `alert_type` and `severity`. This single counter then lets you query: - Total alerts: `clinical_alerts_total` - Only critical threshold breaches: `clinical_alerts_total{severity="Critical"}` - Only SOFA sepsis alerts: `clinical_alerts_total{alert_type="SOFA_SEPSIS"}` ### Histograms A **histogram** tracks the _distribution_ of values, not just a total count. For example, "how long does it take to ingest an observation?" — a counter can tell you how many were ingested, but a histogram tells you that 50% completed in under 10ms, 95% in under 50ms, and the slowest 1% took over 200ms. Histograms work by defining **buckets** — thresholds like 5ms, 10ms, 25ms, 50ms, etc. Each observation is counted into all buckets it falls below. Prometheus then stores `_bucket` (how many observations fell into each bucket), `_sum` (total time across all observations), and `_count` (total number of observations). From these, you can calculate percentiles using the `histogram_quantile()` function in PromQL. ```csharp public readonly Histogram ObservationIngestDuration = Metrics.CreateHistogram( "observation_ingest_duration_seconds", "Ingest transaction duration from request receipt to COMMIT.", new HistogramConfiguration { Buckets = new[] { 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0 } }); public readonly Histogram News2ScoringDuration = Metrics.CreateHistogram( "news2_scoring_duration_seconds", "Time to compute a NEWS2 score from Redis state.", new HistogramConfiguration { Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 } }); public readonly Histogram TrendAnalysisDuration = Metrics.CreateHistogram( "trend_analysis_duration_seconds", "Time to evaluate trend for one observation.", new HistogramConfiguration { Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 } }); public readonly Histogram SofaScoringDuration = Metrics.CreateHistogram( "sofa_scoring_duration_seconds", "SOFA scoring computation time.", new HistogramConfiguration { Buckets = new[] { 0.001, 0.005, 0.01, 0.025, 0.05, 0.1 } }); public readonly Histogram ClinicalSyncBatchDuration = Metrics.CreateHistogram( "clinical_sync_batch_duration_seconds", "Batch processing duration.", new HistogramConfiguration { Buckets = new[] { 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0 } }); ``` **Bucket selection matters**: The ingest histogram uses fine-grained buckets (5ms to 1s) because sub-second latency is critical for patient safety. The sync batch histogram uses coarser buckets (100ms to 10s) because batch operations are inherently slower. ### Gauges A **gauge** is a value that can go up or down — like a thermometer or a fuel gauge. It represents the _current state_ of something: "right now there are 3 unacknowledged alerts" or "the outbox has 47 pending events." Unlike counters (which only increase), gauges are set to an absolute value by background services that periodically check the current state. ```csharp public readonly Gauge AlertsUnacknowledgedGauge = Metrics.CreateGauge( "alerts_unacknowledged_gauge", "Count of open CRITICAL alerts older than 5 minutes with no acknowledgment."); public readonly Gauge KafkaConsumerLag = Metrics.CreateGauge( "kafka_consumer_lag", "Approximate consumer group lag in messages.", labelNames: new[] { "consumer_group" }); public readonly Gauge OutboxPendingEvents = Metrics.CreateGauge( "outbox_pending_events", "Count of outbox events not yet relayed to Kafka."); public readonly Gauge WardGatewaysOffline = Metrics.CreateGauge( "ward_gateways_offline_gauge", "Ward gateways with status OFFLINE or DEGRADED.", labelNames: new[] { "site_code" }); public readonly Gauge WardGatewayBufferDepth = Metrics.CreateGauge( "ward_gateway_buffer_depth", "Reported unsynced event count per gateway.", labelNames: new[] { "gateway_code", "department" }); public readonly Gauge AlertAcknowledgementRate = Metrics.CreateGauge( "vigilcare_alert_acknowledgement_rate", "Alert acknowledgement rate by type.", labelNames: new[] { "alert_type" }); public readonly Gauge AlertFalsePositiveRate = Metrics.CreateGauge( "vigilcare_alert_false_positive_rate", "Clinician-reported false positive rate by type.", labelNames: new[] { "alert_type" }); public readonly Gauge AlertUsefulRate = Metrics.CreateGauge( "vigilcare_alert_useful_rate", "Clinician-reported useful rate by type.", labelNames: new[] { "alert_type" }); public readonly Gauge AlertAvgAckSeconds = Metrics.CreateGauge( "vigilcare_alert_avg_ack_seconds", "Average seconds from trigger to acknowledgement by type.", labelNames: new[] { "alert_type" }); ``` --- ## Step 4: Using Metrics in Application Code ### Inline Counters and Timers In `ObservationService`, the ingest path both times the transaction and increments counters: ```csharp using var timer = _metrics.ObservationIngestDuration.NewTimer(); // ... perform the ingest transaction ... _metrics.ObservationsIngestedTotal .WithLabels(req.ObservationCode, req.Source.ToDbString()) .Inc(); if (alert is not null) { _metrics.ClinicalAlertsTotal .WithLabels(alert.AlertType.ToDbString(), alert.Severity.ToDbString()) .Inc(); } ``` **How does `NewTimer()` work?** In C#, a `using` block runs some cleanup code when the block exits. `NewTimer()` starts a stopwatch and the `using` block ensures it records the elapsed time into the histogram when the code leaves the block — even if an exception is thrown. This means you don't need to manually calculate timing. **What does `WithLabels()` do?** It selects which specific "bucket" of the counter to increment. A counter with labels is really a _family_ of counters — one for each unique label combination. `.WithLabels("HEART_RATE", "DEVICE")` increments the counter for heart rate observations from devices specifically. --- ## Step 5: Background Collectors Gauges can't be updated inline in request handlers because they represent _current state_ ("how many right now?"), not events ("one more just happened"). You need a background process that periodically checks the current state and updates the gauge. In .NET, a `BackgroundService` is a class that runs continuously in the background for the lifetime of the application. Four of these poll data sources on a timer and update gauge values. ### AlertsUnacknowledgedCollector (every 30s) The most clinically significant metric. Queries PostgreSQL for CRITICAL alerts that have been open more than 5 minutes with no acknowledgment: ```csharp protected override async Task ExecuteAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); while (await timer.WaitForNextTickAsync(ct)) await CollectAsync(ct); } private async Task CollectAsync(CancellationToken ct) { await using var scope = _scopes.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); var count = await db.ClinicalAlerts .CountAsync(a => a.Severity == AlertSeverity.Critical && a.Status == AlertStatus.Open && a.TriggeredAt < cutoff, ct); _metrics.AlertsUnacknowledgedGauge.Set(count); if (count > 0) _logger.LogWarning( "[PATIENT-SAFETY] alerts_unacknowledged_gauge={Count}", count); } ``` It also logs a warning when the count is non-zero — this shows up in Seq as a structured log entry tagged `[PATIENT-SAFETY]`. ### OutboxPendingCollector (every 30s) Counts outbox events not yet relayed to Kafka. A growing number means the relay is falling behind or Kafka is unreachable: ```csharp var count = await db.OutboxEvents .CountAsync(e => e.ProcessedAt == null, ct); _metrics.OutboxPendingEvents.Set(count); ``` ### KafkaConsumerLagCollector (every 30s) Measures how far behind each Kafka consumer group is. **Consumer lag** is the number of messages that have been published but not yet processed. Think of it like a queue at a bank — lag is how many people are still waiting. This collector uses Kafka's admin API to check the lag for each consumer group without actually consuming any messages: ```csharp private static readonly string[] Groups = { "es-indexer", "sepsis-engine", "notification-publisher", "data-lake-writer", }; private async Task CollectGroupLagAsync(string groupId, CancellationToken ct) { using var admin = new AdminClientBuilder(adminConfig).Build(); // Get committed offsets for this consumer group var result = await admin.ListConsumerGroupOffsetsAsync(...); // Query high watermarks with a temporary consumer using var tempConsumer = new ConsumerBuilder(config).Build(); long totalLag = 0; foreach (var tpo in partitions) { var watermarks = tempConsumer.QueryWatermarkOffsets( tpo.TopicPartition, TimeSpan.FromSeconds(5)); var lag = watermarks.High.Value - tpo.Offset.Value; totalLag += Math.Max(0L, lag); } _metrics.KafkaConsumerLag.WithLabels(groupId).Set(totalLag); } ``` The lag is per consumer group. If `sepsis-engine` has high lag but `es-indexer` doesn't, the Grafana panel makes it immediately visible which pipeline is falling behind. ### WardGatewayMetricsCollector (every 60s) Queries PostgreSQL for gateway status and buffer depth: ```csharp var offlineBySite = await db.WardGateways .Include(g => g.Site) .Where(g => g.Status != GatewayStatus.Online) .GroupBy(g => g.Site.SiteCode) .Select(g => new { SiteCode = g.Key, Count = g.Count() }) .ToListAsync(ct); foreach (var row in offlineBySite) _metrics.WardGatewaysOffline.WithLabels(row.SiteCode).Set(row.Count); var allGateways = await db.WardGateways.AsNoTracking().ToListAsync(ct); foreach (var g in allGateways) _metrics.WardGatewayBufferDepth .WithLabels(g.GatewayCode, g.Department) .Set(g.ReportedBufferDepth); ``` All four collectors follow the same pattern: 1. **Extend `BackgroundService`** — .NET's base class for long-running background work 2. **Use `PeriodicTimer` for the poll loop** — fires a callback at a fixed interval (30s or 60s) 3. **Create a DI scope per tick** — in .NET's dependency injection (DI), database contexts are "scoped" (one per request/operation). Background services are singletons (one for the whole app), so they need to create a new scope each tick to get a fresh database context 4. **Catch and log errors without crashing the collector** — a transient database timeout shouldn't kill the metrics collection permanently --- ## Step 6: Prometheus Configuration File: `infra/prometheus/prometheus.yml` ```yaml global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: vigilcare_api static_configs: - targets: ["host.docker.internal:5270"] metrics_path: /metrics ``` - **`scrape_interval: 15s`**: Prometheus pulls metrics every 15 seconds. This determines the resolution (granularity) of your data — you'll have one data point every 15 seconds. A shorter interval gives more detail but uses more storage and CPU. - **`host.docker.internal:5270`**: The API runs on the host machine on port 5270. The `extra_hosts` entry in `docker-compose.yml` makes this hostname resolvable from inside the Prometheus container. - **`metrics_path: /metrics`**: Matches the `app.MapMetrics("/metrics")` endpoint in Program.cs. --- ## Step 7: Grafana Provisioning Grafana is configured entirely through file provisioning — no manual setup needed after `docker compose up`. ### Datasource: `infra/grafana/provisioning/datasources/prometheus.yml` ```yaml apiVersion: 1 datasources: - name: Prometheus type: prometheus url: http://prometheus:9090 isDefault: true access: proxy ``` Uses the Docker service name `prometheus` (not `localhost`) because Grafana runs inside the Docker network. ### Dashboard Provider: `infra/grafana/provisioning/datasources/dashboards/config.yml` ```yaml apiVersion: 1 providers: - name: VigilCare type: file options: path: /var/lib/grafana/dashboards ``` Tells Grafana to load all JSON files from `/var/lib/grafana/dashboards`, which is bind-mounted from `./infra/grafana/dashboards/`. --- ## Step 8: Grafana Dashboards ### Clinical Operations Dashboard (`vigilcare.json`) | Panel | Type | Query | Purpose | |-------|------|-------|---------| | Unacknowledged Critical Alerts | Stat | `alerts_unacknowledged_gauge` | Primary safety indicator | | Observation Ingest Rate | Timeseries | `rate(observations_ingested_total[1m])` | Data flow health | | Clinical Alerts by Type (5m) | Bar gauge | `increase(clinical_alerts_total[5m])` | Alert volume breakdown | | Outbox Pending Events | Stat | `outbox_pending_events` | Outbox relay health | | Kafka Consumer Lag by Group | Timeseries | `kafka_consumer_lag` | Per-pipeline lag | | Ingest Latency p50 / p99 | Timeseries | `histogram_quantile(0.50, rate(...[5m]))` / `histogram_quantile(0.99, rate(...[5m]))` | Performance SLA | | Escalations Total | Stat | `escalations_total` | Missed alert tracking | | Ward Gateways Offline | Stat | `sum(ward_gateways_offline_gauge)` | Edge connectivity | | Gateway Buffer Depth | Bar gauge | `ward_gateway_buffer_depth` | Per-gateway backlog | | Clinical Sync Batches (5m rate) | Timeseries | `rate(clinical_sync_batches_total[5m])` | Sync throughput | | Total Sync Backlog | Stat | `sum(ward_gateway_buffer_depth)` | Fleet-wide backlog | ### Alert Quality Dashboard (`alert-quality-dashboard.json`) | Panel | Type | Query | Purpose | |-------|------|-------|---------| | Acknowledgement Rate by Type | Timeseries | `vigilcare_alert_acknowledgement_rate` | Are alerts being seen? | | False Positive Rate by Type | Timeseries | `vigilcare_alert_false_positive_rate` | Alert fatigue tracking | | Useful Rate by Type | Timeseries | `vigilcare_alert_useful_rate` | Clinical value | | Avg Ack Seconds by Type | Timeseries | `vigilcare_alert_avg_ack_seconds` | Response time SLA | --- ## Metrics Reference ### All Counters | Metric | Labels | What It Tracks | |--------|--------|---------------| | `observations_ingested_total` | `observation_code`, `source` | Vital sign ingest volume | | `clinical_alerts_total` | `alert_type`, `severity` | Alert generation rate | | `news2_scores_total` | `risk_level` | NEWS2 scoring frequency | | `escalations_total` | — | Unacknowledged alert escalations | | `trend_alerts_total` | `observation_code` | Rapid deterioration detections | | `alert_suppressions_total` | `alert_type` | Suppression window activations | | `qsofa_detections_total` | — | qSOFA screen triggers | | `sepsis_bundle_compliance_total` | `status` | Bundle compliance outcomes | | `gcs_scores_total` | `classification` | GCS scoring by severity | | `sofa_scores_total` | `has_delta_alert` | SOFA scoring with/without alerts | | `fhir_ingest_total` | `resource_type`, `outcome` | FHIR inbound operations | | `fhir_read_total` | `resource_type`, `interaction`, `outcome` | FHIR read/search operations | | `authorization_failures_total` | `permission`, `role` | Failed auth attempts | | `fhir_mapping_errors_total` | `reason` | FHIR mapping failures | | `phi_access_logs_total` | `access_type` | PHI access audit entries | | `clinical_sync_batches_total` | `status` | Gateway sync batch outcomes | ### All Histograms | Metric | Buckets | What It Tracks | |--------|---------|---------------| | `observation_ingest_duration_seconds` | 5ms–1s | Full ingest transaction time | | `news2_scoring_duration_seconds` | 1ms–100ms | NEWS2 computation time | | `trend_analysis_duration_seconds` | 1ms–100ms | Trend evaluation time | | `sofa_scoring_duration_seconds` | 1ms–100ms | SOFA computation time | | `clinical_sync_batch_duration_seconds` | 100ms–10s | Batch processing time | ### All Gauges | Metric | Labels | Collector | Interval | |--------|--------|-----------|----------| | `alerts_unacknowledged_gauge` | — | `AlertsUnacknowledgedCollector` | 30s | | `kafka_consumer_lag` | `consumer_group` | `KafkaConsumerLagCollector` | 30s | | `outbox_pending_events` | — | `OutboxPendingCollector` | 30s | | `ward_gateways_offline_gauge` | `site_code` | `WardGatewayMetricsCollector` | 60s | | `ward_gateway_buffer_depth` | `gateway_code`, `department` | `WardGatewayMetricsCollector` | 60s | | `vigilcare_alert_acknowledgement_rate` | `alert_type` | `AlertQualityAggregatorService` | 60min | | `vigilcare_alert_false_positive_rate` | `alert_type` | `AlertQualityAggregatorService` | 60min | | `vigilcare_alert_useful_rate` | `alert_type` | `AlertQualityAggregatorService` | 60min | | `vigilcare_alert_avg_ack_seconds` | `alert_type` | `AlertQualityAggregatorService` | 60min | --- ## Accessing the Stack | Tool | URL | Credentials | |------|-----|-------------| | Prometheus | http://localhost:9101 | None | | Grafana | http://localhost:3101 | admin / admin | | Raw metrics | http://localhost:5270/metrics | JWT or anonymous |