Observability & Production Monitoring

Know when the system is working, not just when it's running.

The Silent Failure Problem

A contextual bandit has a failure mode that produces no errors, no latency spikes, and no obvious operational signal: exploration collapse. The engine is healthy. Every prediction succeeds. Every reward matches. The WAL is flushing. But the model stopped learning days ago โ€” one arm has absorbed all traffic and the system is silently serving a static policy.

For enterprise deployments this is a contractual and audit risk. You are paying for an adaptive system. Without explicit observability, you have no way to distinguish "the bandit has correctly converged to a winning arm" from "the bandit collapsed to a wrong arm two weeks ago and nobody noticed."

BanditDB surfaces this through three layered signals: live selection entropy per campaign, an aggregated health endpoint, and a causal validation pipeline.

Selection Entropy

Every call to GET /campaign/:id/diagnostics computes the normalised Shannon entropy of the arm selection distribution:

H = โˆ’ ฮฃ p_i ยท log(p_i) / log(n_arms) n_arms = 3, uniform exploration: p = [0.33, 0.33, 0.33] โ†’ H = 1.00 n_arms = 3, healthy convergence: p = [0.70, 0.16, 0.14] โ†’ H = 0.78 n_arms = 3, warning territory: p = [0.88, 0.07, 0.05] โ†’ H = 0.36 n_arms = 3, critical โ€” collapsed: p = [0.97, 0.02, 0.01] โ†’ H = 0.09

H = 1.0 means perfectly uniform selection across all arms. H = 0.0 means one arm receives all traffic. The normalisation by log(n_arms) makes the scale consistent regardless of how many arms a campaign has.

Entropy Status and Guards

Raw entropy is not sufficient for alerting. A campaign with H = 0.12 could be healthy (one arm has genuinely won) or broken (the model collapsed before accumulating enough data). Two guard conditions prevent false positives before a status is computed:

GuardConditionEffect
Guard 1 โ€” Convergence Leading arm's 95% Wilson-score CI lower bound exceeds the second arm's upper bound (requires โ‰ฅ 30 rewards on both arms) Status is forced to ok. Low entropy is the correct outcome when one arm has statistically won.
Guard 2 โ€” Minimum observations Total predictions < 500 Status is forced to ok. Early in a campaign, random variance naturally concentrates traffic; alerting here would be noise.

When neither guard fires, the status is set by thresholds on H:

entropy_statusThresholdMeaning
okH โ‰ฅ 0.4, or either guard is activeHealthy exploration or confirmed convergence.
warning0.2 โ‰ค H < 0.4One arm is absorbing most traffic without a convergence signal. Investigate.
criticalH < 0.2Near-total collapse. likely_cause and suggested_action are always present.

Entropy Trend

BanditDB stores an entropy snapshot at every checkpoint and compares it to the current value. This distinguishes a campaign that has been collapsed for months (data quality issue) from one that collapsed last night (operational incident).

entropy_trendMeaning
stableEntropy changed by less than 0.1 since the last checkpoint.
fallingDropped by more than 0.1 โ€” recent collapse. Likely cause is a pipeline event, deploy, or new cohort. Correlate with your deployment timeline.
recoveringIncreased by more than 0.1 โ€” entropy is returning after a collapse. Monitor until stable.
unknownNo checkpoint has been written yet for this campaign. Run POST /checkpoint to establish a baseline.

Full Diagnostics Response

A campaign with critical entropy returns a self-explanatory triage payload alongside all existing diagnostics:

curl http://localhost:8080/campaign/prices/diagnostics \
  -H "X-Api-Key: your-key"
{
  "campaign_id": "prices",
  "selection_entropy": 0.09,
  "entropy_status": "critical",
  "entropy_trend": "falling",
  "converged": false,
  "likely_cause": "recent_collapse",
  "suggested_action": "Entropy dropped since last checkpoint. Check reward pipeline for bugs or recent config changes.",
  "total_predictions": 4821,
  "total_rewards": 312,
  "arm_stats": {
    "price_10": { "predictions": 4698, "rewards": 301, "avg_reward": 0.61 },
    "price_15": { "predictions": 89,   "rewards": 8,   "avg_reward": 0.58 },
    "price_20": { "predictions": 34,   "rewards": 3,   "avg_reward": 0.55 }
  }
}

likely_cause and suggested_action are only present when entropy_status is warning or critical โ€” they are omitted entirely when the campaign is healthy.

Operational Recipes

What converged actually tests. It asks whether one arm is best for everybody โ€” not whether the model has learned. On a contextual campaign it stays false permanently, by design: if push notifications win on mobile and email wins on desktop, neither arm is globally better, so the confidence intervals overlap no matter how well the model routes. A campaign running at its theoretical optimum still reports converged: false.

Judge a contextual campaign by its reward-rate trend and by whether traffic splits across arms. Collapsing onto one arm is the signal that the model has stopped using the context โ€” the opposite of what converged would suggest. Reserve converged: true as a meaningful "done" signal for non-contextual campaigns, where one variant genuinely is better for everyone.

When an alert fires, the first step is always to look at the diagnostics: per-arm predictions, rewards, avg_reward, converged, and entropy_trend together identify which of five scenarios is occurring.

ScenarioSignalsAction
Legitimate convergence
(non-contextual campaigns only)
converged: true, all arms have substantial reward counts, losing arms had a fair chance (predictions > 300 each) No action. Guard 1 should have suppressed the alert. If it fired, verify reward count thresholds are met. Consider archiving the campaign.
Early lock-in Total predictions < 2000, one or more arms have fewer than 50 predictions, converged: null The winning arm got a lucky early lead before others had enough data. Increase alpha to boost the UCB exploration bonus. If the campaign is too young to reset, wait โ€” entropy may recover naturally. Reset and restart if data is cheap to regenerate.
Reward pipeline event entropy_trend: "falling", collapse is recent, often correlates with a deploy or config change. One arm's reward rate is anomalously high relative to its base rate. Fix the pipeline before touching the campaign. Resetting while the bug is live just restarts the corruption. Once the pipeline is clean, assess whether the corrupted observations can be discarded or whether a full campaign reset is warranted.
New cohort after collapse Long-running campaign, entropy was healthy for months then gradually declined, correlates with a new user segment or product change. The collapsed arm may be contextually correct for the original cohort but wrong for the new one. Do not reset โ€” that discards valid learning for the original cohort. Create a separate campaign for the new segment, or add new arms representing the new segment's hypotheses.
Alpha misconfiguration Collapse occurs within the first 200โ€“500 predictions, entropy_trend: "falling" from the very beginning, all arms except one have near-zero observations. Diagnosed by reviewing campaign creation parameters. The UCB exploration bonus was too small from the start. Recreate the campaign with a higher alpha. The cost is low โ€” the campaign has few observations.

Health Endpoint Integration

GET /health is the public probe, designed for load balancers, uptime monitors, and Kubernetes readiness checks. It requires no authentication and returns overall status only:

curl http://localhost:8080/health
{ "status": "ok", "version": "2.0.0", "features": ["neural"] }

Per-campaign entropy moved to GET /health/detail in 2.0.0 and requires a reader key. The campaign identifiers it returns carry the tenant prefix in multi-tenant deployments, so serving them from an unauthenticated endpoint would have exposed the tenant list. Results are scoped to the caller's tenant.

curl -H "X-Api-Key: $KEY" http://localhost:8080/health/detail
{
  "status": "degraded",
  "version": "2.0.0",
  "campaigns": {
    "prices":        { "entropy": 0.09, "status": "critical" },
    "recommendations": { "entropy": 0.71, "status": "ok" },
    "onboarding":    { "entropy": 0.34, "status": "warning" }
  }
}
HTTP statusOverall statusMeaning
200okAll active campaigns have healthy entropy (or are statistically converged).
200degradedOne or more campaigns have warning or critical entropy. The service is still serving correctly โ€” this is a data quality signal, not a service failure. Do not remove from load balancer rotation.
503degraded: wal unavailableThe WAL writer has encountered an unrecoverable I/O error. Predictions will continue to be served from memory but no new events are being persisted. Treat as a service failure.

The deliberate choice of HTTP 200 for entropy degradation โ€” rather than 503 โ€” means a k8s readiness probe or load balancer health check will not remove the instance from rotation just because a campaign's model quality has degraded. Service availability and model quality are separate failure modes and should be monitored separately.

Kubernetes

livenessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10

The k8s probe will mark the pod not-ready only when the WAL writer fails (503). Campaign-level entropy degradation returns 200 and does not affect routing โ€” which is the correct behaviour: a degraded model is still serving predictions, and removing the pod from rotation does not fix the underlying cause.

Structured logging

When entropy_status is warning or critical, BanditDB emits a structured WARN log line at diagnostics time. Set LOG_FORMAT=json to receive machine-parseable output compatible with CloudWatch, Datadog, Splunk, and most log aggregation pipelines:

{
  "level": "WARN",
  "campaign": "prices",
  "entropy": "0.091",
  "trend": "Falling",
  "status": "Critical",
  "likely_cause": "recent_collapse",
  "message": "entropy: low selection entropy detected"
}

An alert rule on message = "entropy: low selection entropy detected" and level = WARN is sufficient to wire this into any log-based alerting system without code changes.

Prometheus Metrics

GET /metrics returns Prometheus text format. Since 2.0.0 it requires an API key by default โ€” the output names campaigns and arms, which is tenant-identifying. Set BANDITDB_METRICS_PUBLIC=true to expose it anonymously.

Four of these exist specifically to surface failures that are otherwise silent โ€” the service keeps returning 200s while something degrades:

MetricTypeWhat it tells you
banditdb_interactions_evicted_totalcounter Alert on any increase. A pending prediction was dropped at the cache limit, so its reward can never be matched. The model silently stops learning from those interactions and nothing else reports it.
banditdb_wal_dropped_totalcounter Prediction log records discarded because the WAL writer fell behind. Requests still succeed โ€” latency and error dashboards look clean โ€” but late rewards for those predictions will not match.
banditdb_interactions_pendinggauge Predictions awaiting a reward. Approaching BANDITDB_MAX_PENDING_INTERACTIONS means eviction is imminent.
banditdb_wal_fsync_totalcounter Group-commit fsyncs. Compare against reward rate to see how effectively the commit window is batching.
banditdb_wal_healthygauge 0 means the WAL writer hit an unrecoverable I/O error. All writes are rejected and /health returns 503.
banditdb_wal_channel_availablegauge Free slots in the WAL queue. Sustained near zero precedes dropped predictions and rejected rewards.
banditdb_arm_predictions_total
banditdb_arm_rewards_total
counter Per campaign and arm. A flat distribution means no learning; collapse onto one arm means either convergence or entropy collapse โ€” /campaign/:id/diagnostics distinguishes them.
banditdb_campaigns_active
banditdb_campaigns_archived
gauge Campaign counts.
banditdb_tournament_traffic_bpsgauge Progressive challenger traffic share in basis points (1000 = 10%).
banditdb_http_requests_total
banditdb_http_request_duration_seconds
counter
histogram
Per endpoint. Reward latency is the one to watch โ€” it blocks on fsync, so a rising p99 usually indicates disk latency rather than application load.

Two things deliberately have no alert. The fsync interval is measured to be nearly irrelevant across a 20× range, because the writer also syncs whenever it goes idle. And checkpoint frequency controls WAL size and replay time, not durability โ€” acknowledged writes are already on disk.

Causal Validation

Entropy alerting tells you whether the bandit is still exploring. Causal analysis tells you whether the exploration it did was causally correct โ€” whether the arm receiving the most traffic is actually causing better outcomes, or whether it is merely correlated with outcomes that were already good for that user segment.

Standard OPE estimators (IPS, SNIPS, Doubly Robust) answer the question: "what reward would policy ฯ€ achieve?" Causal analysis answers a different question: "what is the causal effect of each arm, controlling for the fact that the bandit was selecting them non-randomly based on context?"

The distinction matters in production. A bandit can correctly route high-converting users to arm A โ€” not because arm A causes conversions, but because arm A was selected for users who already convert well. IPS estimators will confirm arm A is performing well. Causal analysis will correctly identify that the arm's observed advantage is partially or fully explained by user selection bias.

Running the analysis

# Checkpoint first to flush the latest data to Parquet
curl -X POST http://localhost:8080/checkpoint -H "X-Api-Key: your-key"

# Run causal analysis
pip install econml scikit-learn polars pandas numpy
python scripts/causal_analysis.py \
  --parquet /data/exports/prices.parquet \
  --features price_sensitivity recency_days cart_value_norm segment_score

The script works with LinUCB, Thompson Sampling, and Progressive campaigns. Thompson Sampling campaigns now log per-prediction propensities via adaptive Monte Carlo sampling (N=8โ€“64 draws per arm, driven by posterior spread). The causal estimator (CausalForestDML) does not use these logged propensities โ€” it learns the selection propensity internally from observed (arm, context) pairs using Double Machine Learning, which is more appropriate for aggregated bandit data than using time-varying logged propensities from a non-stationary policy. The logged TS propensities are used by the IPS/SNIPS offline evaluator in banditdb.eval.

Section 0 โ€” Positivity & Confounding Diagnostics

This section runs before the expensive causal forests and checks whether the DML validity assumptions hold. It is the causal analysis equivalent of the entropy alert: a positivity violation means the bandit never selected a given arm for some region of the feature space, making causal effect estimates for that arm in that region extrapolation rather than inference.

โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
0. POSITIVITY & CONFOUNDING DIAGNOSTICS
   (pre-flight check โ€” run before fitting causal forests)
โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
  Arm                               AUC    P<0.05   P>0.95
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  price_10                          0.812    2.1%     1.8%
  price_15                          0.831    1.4%     0.9%
  price_20                          0.744   34.2%     0.3%   โš  positivity violation

  โš   Positivity violations often indicate exploration collapse.
     Check GET /campaign/:id/diagnostics for entropy_status.

The AUC of the internal propensity model (model_t) quantifies how strongly context predicts arm selection. AUC โ‰ˆ 0.5 means near-random selection โ€” the bandit was exploring freely and causal estimates are highly reliable. AUC > 0.8 means strong confounding โ€” the DML is doing important correction work but estimates carry more uncertainty because few users in the "wrong" arm condition exist.

P<0.05 is the fraction of observations where the model predicts near-zero probability of this arm being selected. When this exceeds 20%, the arm was almost never chosen in that feature region and the CATE estimate there is unreliable.

Section 2 โ€” Causal Assignment vs Bandit Selection

The most operationally important output. The causal forest identifies, for each user in the dataset, which arm would causally produce the best outcome. This is compared to what the bandit actually selected.

โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
2. CAUSAL ASSIGNMENT vs BANDIT SELECTION
โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
  Arm                              Causal   Bandit     Gap
  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€  โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  price_10                          42.1%    14.2%   +27.9%  โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
  price_15                          51.3%    79.4%   -28.1%  โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ
  price_20                           6.6%     6.4%    +0.2%  โ–ˆโ–ˆโ–ˆ
GapInterpretationAction
Gap โ‰ˆ 0The bandit's traffic distribution matches the causal structure. The model has converged to the correct policy.No action. If entropy is also healthy, the campaign is operating correctly.
Gap > 0 (arm underserved)A user group that would causally benefit from this arm is not being routed to it. The bandit is leaving value on the table for that segment.Check whether the arm had enough early observations. Consider raising alpha or adding context features that distinguish this group.
Gap < 0 (arm overserved)The bandit has over-converged to this arm. It is receiving more traffic than the causal structure warrants โ€” often driven by user selection bias rather than genuine causal advantage.The arm's observed reward rate is inflated by non-random selection. Do not interpret high reward rate as causal effectiveness without this analysis.

Section 5 โ€” Selection Stability Over Time

Splits the campaign timeline into five equal-sized buckets (by prediction timestamp) and shows per-arm selection rate across the full campaign history. This surfaces convergence, collapse, and pipeline events directly from the Parquet export without requiring access to the live diagnostics endpoint.

โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
5. SELECTION STABILITY OVER TIME
โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
  t1(961)  t2(961)  t3(961)  t4(961)  t5(961)

  price_10   โ–‘โ–‘โ–’โ–’โ–’  [ 18.2%  22.1%  28.4%  31.7%  35.9%]
  price_15   โ–ˆโ–ˆโ–ˆโ–ˆโ–“  [ 64.1%  59.3%  55.2%  52.8%  47.3%]
  price_20   โ–’โ–‘โ–‘โ–‘ยท  [ 17.7%  18.6%  16.4%  15.5%  16.8%]

  Stable across buckets  โ†’ IID assumption holds, DML estimates reliable
  Monotonic increase     โ†’ bandit converging or collapsing to this arm
  Sudden jump in t4/t5   โ†’ possible reward pipeline event or config change

The example above shows healthy convergence in progress: price_15 is steadily losing traffic to price_10 as the model learns. The DML IID assumption is not perfectly satisfied (the policy is non-stationary) but the gradual shift means causal estimates over the full history are a reasonable approximation of the average policy. A sudden jump in t4 or t5 โ€” rather than a gradual drift โ€” would indicate a pipeline or configuration event and should prompt investigation before trusting the causal estimates.

Interpreting the full output

SignalWhat it meansWhat to do
ATE significant & positiveThe arm causally increases reward above baseline.This arm is genuinely effective. Its observed advantage is not explained by selection bias.
ATE near zero or inconclusiveThe arm's observed reward advantage is not causally real โ€” it is explained by the context features it tends to be selected for.Audit whether context features are capturing the relevant signal. The arm may be receiving credit for conversions that would have happened anyway.
ATE significant & negativeThe arm is causally hurting outcomes.Remove or replace the arm. Its selection is suppressing reward relative to what users would have achieved under a different policy.
CATE p25โ€“p75 wideThe treatment effect is heterogeneous โ€” some users respond strongly, others do not. Personalisation is the mechanism driving value.The bandit's context-aware routing is doing important work. Inspect the winning segments output for which feature dimensions drive the heterogeneity.
CATE p25โ€“p75 narrowThe effect is homogeneous โ€” the arm is roughly equally good or bad for all users.The bandit is correct to converge uniformly. A simpler rule-based policy would achieve similar results.
Positivity violation > 20%The bandit collapsed exploration for this arm. CATE estimates are not trustworthy for the affected feature region.Check entropy_status in /diagnostics. Determine which operational recipe applies and address the root cause before re-running causal analysis.

Causal analysis requires at least 200โ€“300 reward observations per arm for reliable CATE estimates. Run POST /checkpoint to export the latest data before each analysis run. The causal forest fits one model per arm, so runtime scales linearly with the number of arms โ€” allow 2โ€“5 minutes for campaigns with 5+ arms and 10,000+ observations.