# Training Binary Observability + Grafana Dashboards Design

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Instrument training binaries with GPU metrics polling, per-epoch Prometheus metrics, and OTLP tracing so we can validate whether the GPU is maxed out during real DQN/PPO training runs. Deploy Grafana dashboards to visualize everything.

**Architecture:** Training binaries poll `nvidia-smi` every 5s for GPU metrics, emit per-fold training metrics via existing Prometheus helpers, and export OTLP traces to Tempo. Grafana consumes Prometheus (metrics), Loki (logs), and Tempo (traces) with cross-linking.

**Tech Stack:** Rust tracing + tracing-opentelemetry (already in workspace), Prometheus client (already used), nvidia-smi CLI, Grafana 8.x dashboards (JSON ConfigMaps).

---

## Problem Statement

The training binaries (`train_baseline_rl`, `train_baseline_supervised`, `hyperopt_baseline_rl`, `hyperopt_baseline_supervised`) have a Prometheus metrics server running on port 9094 with 18 training metrics registered, but:

1. **Zero GPU metrics emitted** — `nvidia-smi` is called once at startup for batch sizing, never polled during training
2. **`train_baseline_rl.rs` is a metrics black box** — DQN/PPO trainers are delegated to but never call back into `common::metrics::training_metrics`. Only `record_data_load()` and `set_active_workers()` are called
3. **No OTLP tracing** — training binaries use plain `tracing_subscriber::fmt()`, not `init_observability()`
4. **No `OTEL_EXPORTER_OTLP_ENDPOINT`** in K8s training job template
5. **No Grafana dashboards** for training or traces (design exists, implementation doesn't)

## Data Flow

```
Training Binary → nvidia-smi poll (5s) → Prometheus gauges → /metrics :9094 → Prometheus scrape → Grafana
Training Binary → set_epoch_loss() etc. → Prometheus gauges → /metrics :9094 → Prometheus scrape → Grafana
Training Binary → init_observability() → OTLP gRPC :4317 → Tempo → Grafana (Traces)
Training Binary → stdout → containerd → Promtail → Loki → Grafana (linked via trace_id)
```

## Component 1: GPU Metrics Polling

New file: `crates/common/src/metrics/gpu_metrics.rs`

### Metrics Registered

| Metric Name | Type | Description |
|---|---|---|
| `foxhunt_gpu_utilization_percent` | Gauge | SM utilization 0-100 |
| `foxhunt_gpu_memory_used_bytes` | Gauge | VRAM used in bytes |
| `foxhunt_gpu_memory_total_bytes` | Gauge | VRAM total in bytes |
| `foxhunt_gpu_temperature_celsius` | Gauge | GPU temperature |
| `foxhunt_gpu_power_draw_watts` | Gauge | Power consumption |

### Implementation

- `poll_gpu_metrics()` — shells out to `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw --format=csv,noheader,nounits`, parses CSV, sets gauges. MiB values from nvidia-smi are converted to bytes. Silently logs warning and skips update on parse failure (nvidia-smi not available on CPU-only machines).
- `start_gpu_poller(interval_secs: u64)` — spawns a daemon thread named `"gpu-metrics-poller"` that calls `poll_gpu_metrics()` in a loop with `std::thread::sleep(Duration::from_secs(interval_secs))`. Returns immediately. Thread is detached (no join handle needed).

### Integration

All 4 training binaries call `gpu_metrics::start_gpu_poller(5)` in `main()` right after `metrics_server::start_metrics_server(9094)`.

## Component 2: Wire Per-Epoch Metrics into train_baseline_rl.rs

Since DQN/PPO trainers return only a single `f64` (best validation loss), we instrument at the **fold level** in the binary, not inside the trainers.

### Per-Fold Metrics Calls

After each DQN/PPO fold completes:
```rust
metrics::set_epoch(model_name, fold_idx, fold_idx as f64);  // fold as "epoch"
metrics::set_epoch_loss(model_name, fold_idx, best_val_loss);
metrics::set_validation_loss(model_name, fold_idx, best_val_loss);
metrics::set_iteration_seconds(model_name, fold_idx, fold_elapsed.as_secs_f64());
```

After data loading:
```rust
metrics::record_data_load(model_name, load_elapsed.as_secs_f64());  // already done
```

On checkpoint save (in the checkpoint callback):
```rust
metrics::record_checkpoint_save(model_name, fold_idx, ckpt_bytes.len(), save_elapsed);
```

### Hyperopt Binaries

`hyperopt_baseline_rl.rs` and `hyperopt_baseline_supervised.rs` — add `set_epoch()` and `set_epoch_loss()` per trial iteration. Lower priority since hyperopt is exploratory, but the metrics are cheap.

## Component 3: OTLP Tracing in Training Binaries

### Change

In all 4 training binary `main()` functions, replace:
```rust
tracing_subscriber::fmt()
    .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
    .init();
```

With:
```rust
common::observability::init_observability(
    "train_baseline_rl",  // service name varies per binary
    std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok().as_deref(),
);
```

When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, `init_observability` falls back to console-only logging (same as today). When set, spans flow to Tempo.

### K8s Training Job Template

Add to `infra/k8s/training/job-template.yaml` pod env:
```yaml
- name: OTEL_EXPORTER_OTLP_ENDPOINT
  value: "http://tempo.foxhunt.svc.cluster.local:4317"
```

## Component 4: Grafana Training Cockpit Dashboard

New file: `infra/k8s/monitoring/dashboards/foxhunt-training-cockpit.json`

Provisioned as a ConfigMap, auto-loaded by Grafana's dashboard provisioner (same pattern as existing `foxhunt-cockpit.json`).

### Dashboard Layout (8 rows)

Per the existing design doc's Component 4 spec, using the actual metric names from `common/src/metrics/training_metrics.rs` and the new GPU metrics:

| Row | Focus | Panels |
|---|---|---|
| 1 | Active Jobs | active_workers gauge, fold progress |
| 2 | Training Progress | epoch_loss + validation_loss timeseries, batches_per_second, iteration_seconds |
| 3 | GPU Resources | gpu_utilization_percent, gpu_memory_used/total, gpu_temperature, gpu_power_draw |
| 4 | Model Quality | eval_accuracy, eval_f1, eval_precision, eval_recall |
| 5 | Checkpoints | checkpoint_saves_total, checkpoint_failures_total, checkpoint_duration_seconds, checkpoint_size_bytes |
| 6 | Data Pipeline | batches_processed, data_load_seconds |
| 7 | Errors | nan_detected_total, gradient_explosion_total, feature_errors_total |
| 8 | Timing | iteration_seconds histogram, data_load_seconds histogram |

### Template Variables

- `$model` — dropdown: `dqn`, `ppo`, `tft`, etc. Filters all panels by `model` label
- `$fold` — dropdown: `0`, `1`, `2`, etc. Filters by `fold` label
- `$interval` — auto interval for rate() queries

## Component 5: Grafana Traces Dashboard

New file: `infra/k8s/monitoring/dashboards/foxhunt-traces.json`

- Service map panel (Tempo node graph)
- Trace search table (service, operation, duration, status filters)
- Trace duration histogram
- Error rate by service timeseries

## Component 6: Existing Dashboard Updates

- `foxhunt-cockpit.json` — add one "Trace Health" row: spans/sec and error rate from Tempo metrics generator
- Service annotations — add Prometheus scrape annotations to 8 service deployment YAMLs (per existing design doc Component 1)

## Component 7: Service OTLP Wiring

Per existing design doc Component 2: Add `init_tracing()` call to 8 service `main()` functions. Add `#[instrument(skip_all)]` to ~20-30 key async functions (gRPC handlers, DB queries, ML inference paths). No instrumentation on hot-path tick processing.

## File Changes Summary

| Category | Files | Change |
|----------|-------|--------|
| New GPU metrics module | `common/src/metrics/gpu_metrics.rs` | nvidia-smi polling + 5 gauges |
| Training binary metrics | `ml/examples/train_baseline_rl.rs` | Wire per-fold metrics calls |
| Training binary OTLP | 4 training binary `main()` functions | Replace fmt with init_observability |
| K8s training job | `infra/k8s/training/job-template.yaml` | Add OTEL endpoint env var |
| New dashboards | 2 JSON files | training-cockpit, traces |
| Updated dashboards | 1 JSON file | cockpit (trace row) |
| K8s service annotations | 8 service YAMLs | Prometheus scrape annotations |
| Service OTLP | 8 service main.rs | Add init_tracing() |
| Instrumentation | ~20-30 key functions | `#[instrument(skip_all)]` |

## What We Don't Change

- No changes to DQN/PPO trainer internals (instrument at fold level in binaries)
- No changes to hot-path tick processing (no `#[instrument]` on SIMD/RDTSC paths)
- No new Rust dependencies (tracing-opentelemetry already in workspace)
- No changes to Tempo, Loki, or Prometheus deployment configs
- Existing infra cockpit layout stays as-is (just one row added)

## Risks and Mitigations

- **nvidia-smi subprocess overhead**: 5s poll interval, `nvidia-smi` takes ~50ms. 1% overhead on a 5s window. Acceptable.
- **nvidia-smi unavailable**: CPU-only dev machines don't have it. Poller logs a warning once and exits the thread gracefully.
- **Trace volume from training**: Training runs are ~30-60min with ~1000 folds. At one span per fold, volume is negligible (~1000 spans per job).
- **Metric cardinality**: `model` × `fold` labels. Max ~10 models × ~50 folds = 500 series. Low cardinality.
