feature: Explainable Alerts
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
# 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
|
||||
<!-- VigilCareClinicalAPI.csproj -->
|
||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
```
|
||||
|
||||
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<ClinicalMetrics>();
|
||||
|
||||
// ... 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<AppDbContext>();
|
||||
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<Ignore, Ignore>(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 |
|
||||
Reference in New Issue
Block a user