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 = 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:
| Guard | Condition | Effect |
|---|---|---|
| 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_status | Threshold | Meaning |
|---|---|---|
ok | H โฅ 0.4, or either guard is active | Healthy exploration or confirmed convergence. |
warning | 0.2 โค H < 0.4 | One arm is absorbing most traffic without a convergence signal. Investigate. |
critical | H < 0.2 | Near-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_trend | Meaning |
|---|---|
stable | Entropy changed by less than 0.1 since the last checkpoint. |
falling | Dropped by more than 0.1 โ recent collapse. Likely cause is a pipeline event, deploy, or new cohort. Correlate with your deployment timeline. |
recovering | Increased by more than 0.1 โ entropy is returning after a collapse. Monitor until stable. |
unknown | No 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.
| Scenario | Signals | Action |
|---|---|---|
| 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 status | Overall status | Meaning |
|---|---|---|
200 | ok | All active campaigns have healthy entropy (or are statistically converged). |
200 | degraded | One 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. |
503 | degraded: wal unavailable | The 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:
| Metric | Type | What it tells you |
|---|---|---|
banditdb_interactions_evicted_total | counter | 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_total | counter | 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_pending | gauge | Predictions awaiting a reward. Approaching
BANDITDB_MAX_PENDING_INTERACTIONS means eviction is imminent. |
banditdb_wal_fsync_total | counter | Group-commit fsyncs. Compare against reward rate to see how effectively the commit window is batching. |
banditdb_wal_healthy | gauge | 0 means the WAL writer hit an unrecoverable I/O error. All
writes are rejected and /health returns 503. |
banditdb_wal_channel_available | gauge | Free slots in the WAL queue. Sustained near zero precedes dropped predictions and rejected rewards. |
banditdb_arm_predictions_totalbanditdb_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_activebanditdb_campaigns_archived | gauge | Campaign counts. |
banditdb_tournament_traffic_bps | gauge | Progressive challenger traffic share in basis points (1000 = 10%). |
banditdb_http_requests_totalbanditdb_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% โโโ
| Gap | Interpretation | Action |
|---|---|---|
| Gap โ 0 | The 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
| Signal | What it means | What to do |
|---|---|---|
| ATE significant & positive | The arm causally increases reward above baseline. | This arm is genuinely effective. Its observed advantage is not explained by selection bias. |
| ATE near zero or inconclusive | The 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 & negative | The 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 wide | The 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 narrow | The 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.