Node Observability
Scrape a Chio node with Prometheus and join each request's trace to the signed receipt that records its decision.
On this page
Prometheus scrapes the sidecar's /metrics route, a collector carries its spans to Tempo and its logs to Loki, and its signed receipts stay in the receipt store for SIEM export.
The steps use the HTTP sidecar, chio api protect. The MCP edge serves the same Prometheus text at /admin/metrics.
Each signal leaves the sidecar by its own route
Metrics leave through the gated /metrics route, spans and logs through OTLP to a collector, and signed receipts through the receipt store. Orchestrator probes reach the ungated /chio/live and /chio/health routes. Sidecar HTTP service.
Metrics and traces leave the sidecar unsigned, bound for operational backends. Receipts leave signed by the kernel key, so they are the signal you hand to an auditor.
The kernel appends each signed receipt to the store named by --receipt-store, and chio-wall siem-export reads that file read-only and delivers each receipt at least once. Orchestrator probes call /chio/live for container liveness and /chio/health for platform readiness; neither route takes a token.
1. Scrape /metrics with the control token
The sidecar mounts /metrics on its ingress port, the one --listen binds, ahead of the catch-all proxy route. The route sits behind the sidecar-control middleware that gates the approval routes, so the scraper presents the control token the deployment already provisions.
scrape_configs:
- job_name: 'chio-sidecar'
scrape_interval: 15s
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /etc/prometheus/chio-sidecar-control-token
static_configs:
- targets:
- chio-sidecar:9090Check the target by hand with the same token.
curl -s -H "Authorization: Bearer $CHIO_SIDECAR_CONTROL_TOKEN" \
http://127.0.0.1:9090/metrics | grep -E '^chio_receipt_'chio_receipt_uncheckpointed_seq_range 0 chio_receipt_seconds_since_last_checkpoint 42
You should see the two receipt watchdog gauges in a text/plain; version=0.0.4 body; a refused request means the scraper lacks the control token. The reference ECS task definition advertises the route with "prometheus.io/path": "/metrics" and "prometheus.io/port": "9090", and the Cloud Run and Azure manifests do the same for their own container port.
2. Send spans and logs through a collector
The sidecar exports OTLP to CHIO_OTEL_RECEIPT_EXPORTER_ENDPOINT, or to OTEL_EXPORTER_OTLP_ENDPOINT when the first is unset; chio doctor reports the endpoint in that order. A standard OpenTelemetry Collector aggregates everything except receipts, which go to the receipt store first.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
# Strip high-cardinality attributes from anything bound for Prometheus.
# These attributes are safe on spans and logs; they are not safe as
# Prometheus label values.
attributes/strip-cardinality:
actions:
- key: gen_ai.tool.call.id
action: delete
- key: chio.receipt.id
action: delete
- key: chio.replay.run_id
action: delete
- key: chio.tenant.id
action: delete
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
loki:
endpoint: http://loki:3100/loki/api/v1/push
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/tempo]
logs:
receivers: [otlp]
processors: [batch]
exporters: [loki]You should see traces in Tempo and log lines in Loki within the 5 s batch timeout. The attributes/strip-cardinality processor removes four attributes before anything Prometheus-shaped sees them.
3. Join a trace to its receipt
Pass a W3C traceparent header from the upstream caller. The kernel joins that trace and emits its spans as children, named gen_ai.tool.call with operation tool.call, both constants in chio_kernel::otel. The receipt then carries provenance.otel.trace_id and provenance.otel.span_id.
{ span.chio.receipt.id = "$receipt_id" }You should see every trace that touched the receipt. This query drives the Tempo span timeline dashboard; the Jaeger dashboard pivots the same way from receipt id to trace.
4. Import the Grafana dashboards
The dashboards ship as JSON under deploy/dashboards/ in the chio repository. With Grafana running, run the import loop from a clone of it.
find deploy/dashboards -name '*.json' -print0 | \
while IFS= read -r -d '' dashboard; do
jq -n --argjson dashboard "$(cat "$dashboard")" \
'{dashboard: $dashboard, overwrite: true}' \
| curl -fsS -H 'Content-Type: application/json' \
-X POST http://admin:admin@127.0.0.1:3000/api/dashboards/db -d @- \
>/dev/null
doneYou should see the dashboards at http://localhost:3000 under the chio tag. An empty panel usually means a span or log line lacks one of the fields the dashboards expect.
A compose stack beside a sidecar
This stack runs an OTel collector, Prometheus, Loki, Tempo, and Grafana beside a sidecar. The sidecar image's CMD is ["--help"], so its service passes a subcommand; the arguments are the ones the reference ECS task definition uses.
services:
chio-sidecar:
image: chio-sidecar:latest
command:
- api
- protect
- --upstream
- http://app:8080
- --spec
- /etc/chio/spec/openapi.yaml
- --listen
- 0.0.0.0:9090
- --receipt-store
- /var/lib/chio/receipts.db
- --authority-seed-file
- /etc/chio/seed/authority.seed
environment:
CHIO_LOG_LEVEL: info
CHIO_SIDECAR_CONTROL_TOKEN: ${CHIO_SIDECAR_CONTROL_TOKEN}
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
ports:
- "9090:9090" # proxy ingress, /chio/health, /chio/live, /metrics
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector.yaml"]
volumes:
- ./otel-collector.yaml:/etc/otel-collector.yaml:ro
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
loki:
image: grafana/loki:latest
ports:
- "3100:3100"
tempo:
image: grafana/tempo:latest
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./tempo.yaml:/etc/tempo.yaml:ro
grafana:
image: grafana/grafana:latest
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
ports:
- "3000:3000"
volumes:
- ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/ds.yaml:ro5. Load the alert rules
Copy chio-recording-rules.yml and chio-alert-rules.yml from deploy/prometheus/ and list them in prometheus.yml. The recording rules precompute the error ratios and p95 series; the alert rules layer two-window burn-rate and missing-data alerts on top.
rule_files:
- /etc/prometheus/chio-recording-rules.yml
- /etc/prometheus/chio-alert-rules.yml- alert: ChioReceiptWriteErrorBudgetBurn1h
expr: |
chio:receipt_write_error_ratio_1h > (14.4 * 0.001)
and
chio:receipt_write_error_ratio_6h > (6 * 0.001)
for: 2m
labels:
severity: p1
notification_route: pagerduty
opsgenie: "true"
slo: receipt-write
annotations:
summary: Chio receipt writes are burning error budget
runbook: docs/operator-runbook/slo.mdYou should see fourteen Chio alert rules in one group on the Prometheus alerts page. Receipt writes hold a 0.1% error budget; the short window catches a fast burn and the long window suppresses flapping.
Receipts settle disputes; metrics and traces find incidents
Ask each signal the question it answers.
| Signal | Question it answers | Backend | Signing and retention |
|---|---|---|---|
| Receipts | What did Chio decide for this request? | Receipt store and SIEM | By the kernel key; kept per your archive policy |
| Traces | Where did this request spend its time, and which guards ran? | Tempo or Jaeger | Unsigned; kept per your log retention |
| Metrics | Is the deny rate spiking? Are guards exhausting fuel? | Prometheus or OTel metrics | Unsigned and aggregated; kept per your TSDB retention |
For dispute resolution and compliance evidence, query receipts. For the health of the node right now, read metrics. To explain why request X failed at 03:14, read its trace and join it to its receipt. If you find yourself grepping log lines to learn whether a call was denied yesterday, use ReceiptQuery instead; SIEM Export carries receipts to long-term retention with a sequence cursor, and the Receipt Dashboard gives operators a receipt-first view beside Grafana.
Read more in Receipts & Audit.
Four queries answer most questions
Paste these into Grafana Explore or the Prometheus console. The rule pack turns the same signals into paging alerts.
- Deny rate per guard.
rate(chio_guard_deny_total[5m])grouped byguard_id, reason_class. A jump above a guard's baseline is the early warning for both attacks and policy regressions. - Fuel exhaustion tail.
histogram_quantile(0.99, ...)onchio_guard_eval_duration_secondswithverdict="deny", joined againstchio_guard_deny_total{reason_class="fuel"}. It shows WASM modules approaching their fuel ceiling. - External-guard call failures. The circuit breaker logs no transitions of its own; every failed provider call logs
tracing::warn!(guard, error, "external guard failed")and pushes the breaker toward open. A sustained rate perguardmeans a provider is degraded and the breaker is shedding load; see External Guard Adapters. - Reload outcomes.
chio_guard_reload_totalgrouped byoutcome. A risingcanary_failedorrolled_backcount means hot reloads are not landing cleanly.
Every Prometheus label has a finite domain
The exporter strips a fixed set of four attributes from anything bound for Prometheus.
pub const PROMETHEUS_DENIED_ATTRIBUTES: [&str; 4] = [
ATTR_GEN_AI_TOOL_CALL_ID,
ATTR_CHIO_RECEIPT_ID,
ATTR_CHIO_REPLAY_RUN_ID,
ATTR_CHIO_TENANT_ID,
];chio.tenant.id is on that list even though the dashboards need it on spans and log lines. The sink decides: tenant id is a filter dimension in Loki and Tempo and an unbounded label domain in Prometheus. Guard labels stay finite too: the registry caps unique guard ids at 1,024, and reason_class folds every unrecognized deny reason into other, so reason_class="other" on a dashboard means the classifier chose the fallback. Hitting the cap drops the guard from metrics only; the kernel still runs it and still records receipts, so alert on E_GUARD_METRIC_CARDINALITY_EXCEEDED in the logs.
Read more in Fail-Closed Semantics.
Log lines name the guard; receipts carry the decision
Every Chio crate logs through tracing, and guard spans use the chio.guard target, because EnvFilter directives match targets. Guards that log share one convention, a guard field carrying the kebab-case Guard::name(), the same string the receipt records as GuardEvidence.guard_name. To correlate one decision, use a request, trace, or receipt id; the log levels and representative call sites are in the reference below.
Each failure surfaces as a signal you can page on
A dashboard panel renders empty
A span or log line lacks a field the dashboard filters on. Compare your telemetry against the span and log fields listed in the reference; chio.receipt.id and chio.tenant.id are the usual gaps.
A MetricsMissing alert fires
Prometheus has seen no sample of the series for 10 minutes. Check that the target is up and that the scraper still presents a valid control token, then check the process that emits the series.
An OTel drop counter rises
chio_otel_ingress_drop_total counts trace batches the bounded ingress queue dropped; chio_otel_sink_drop_total counts batches whose receipt append failed. Size the queue for the span rate, then check the receipt store's writer.
ChioReceiptCheckpointStale pages
No receipt checkpoint has been written for a day, even if writes still commit. Run chio receipt checkpoint status and restore checkpointing before the uncheckpointed range grows further.
Metric families, rules, and dashboards
Search for a metric, a label, an alert, or a dashboard.
What /metrics carries
The sidecar composes the body from three renderers. Knowing which one owns a family tells you where to look when a series goes missing.
/// Compose the Prometheus scrape body from the kernel guard families, the
/// http-core mediation-edge families, and the alert-pack families.
async fn handle_metrics() -> impl axum::response::IntoResponse {
let alert_pack = || {
let mut out = String::new();
chio_metrics_spec::runtime::render_alert_pack_families(&mut out);
out
};
let body = chio_metrics_spec::runtime::compose_metrics_body(&[
&chio_kernel::render_guard_metrics_prometheus,
&chio_http_core::metrics::render_http_core_metrics_prometheus,
&alert_pack,
]);
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
body,
)
}| Renderer | Families it writes |
|---|---|
chio_ | The seven guard families, the two OTel drop counters, chio_, chio_, chio_, and the two receipt watchdog gauges. |
chio_ | chio_ over guard="http_authority" and its three outcomes, and chio_. |
chio_ | The eight families the alert rules burn against: fail-open, dispatch failure, revocation lag, DLQ depth, SOC export count and lag, alert dispatch count and latency. |
The receipt watchdog gauges are chio_receipt_uncheckpointed_seq_range and chio_receipt_seconds_since_last_checkpoint. The second measures checkpoint progress, deliberately: in a store where writes keep committing while checkpointing stalls, a commit-based gauge would read near zero and the staleness alert would never fire.
The three guard descriptor tables
chio_wasm_guards::metrics declares three static descriptor tables, and the table a registry serves is a constructor argument. GuardMetricRegistry::with_families(families, max_guards) is the one constructor; new() and with_max_guards(n) wrap it with GUARD_METRIC_FAMILIES. That first table holds seven of the thirteen declared families and none of the tenant-labelled ones.
| Table | Served by | On the sidecar scrape |
|---|---|---|
GUARD_ | Guard, with_max_guards(n), register_ | Yes, through the kernel renderer |
GUARD_ | register_ and Guard, held by the wasmtime guard pool | No; read per tenant through Guard |
RUNTIME_ | A registry built with with_families | Each of the three, through the kernel renderer |
The seven guard families
GUARD_METRIC_FAMILIES, each labelled with guard_id. A guard can register metrics of its own; see Custom Guards.
| Metric | Kind | Labels | Unit |
|---|---|---|---|
chio_ | Histogram | guard_id, verdict | seconds |
chio_ | Counter | guard_id | fuel units |
chio_ | Counter | guard_id, verdict | count |
chio_ | Counter | guard_id, reason_class | count |
chio_ | Counter | guard_id, outcome | count |
chio_ | Histogram | guard_id, host_fn | seconds |
chio_ | Gauge | guard_id, epoch | bytes |
The three pool families
GUARD_POOL_METRIC_FAMILIES, each carrying guard_id and tenant_id. A blank tenant folds to unknown and an over-cap tenant to __overflow__, so the label domain stays finite whatever a caller passes.
| Metric | Kind | Unit |
|---|---|---|
chio_ | Counter | count |
chio_ | Gauge | instances |
chio_ | Counter | count |
The three runtime families
RUNTIME_METRIC_FAMILIES: the signing-queue counter and the two OTel drop counters. The grafana/chio-perf.json dashboard plots all three.
| Metric | Kind | Labels | Unit |
|---|---|---|---|
chio_ | Counter | reason | count |
chio_ | Counter | none | count |
chio_ | Counter | none | count |
Histogram buckets
Both histogram families use one fixed bucket set for every guard, so cross-guard quantiles compare without per-guard configuration.
pub const EVAL_DURATION_BUCKETS_SECONDS: &[f64] = &[
0.0001, 0.0005, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0,
];
pub const HOST_CALL_DURATION_BUCKETS_SECONDS: &[f64] = &[
0.00001, 0.00005, 0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1,
];Label vocabulary
| Label | Constant | Values |
|---|---|---|
verdict | VERDICT_ | allow, deny, rewrite, error |
reason_class | REASON_ | policy, pii, secret, prompt_, oversize, fuel, trap, malformed, other |
host_fn | HOST_ | log, get_config, get_, fetch_blob |
outcome | RELOAD_ | applied, canary_failed, rolled_back |
classify_deny_reason_class keeps chio_guard_deny_total{reason_class} finite. It substring-matches a guard's free-form deny reason and returns only a listed value, folding anything unrecognized or absent into other. Which denials carry the label at all is set out in Fail-Closed Semantics.
Cardinality limits
The registry caps unique guard ids at MAX_GUARD_METRIC_CARDINALITY = 1024. Beyond that, registration fails closed with GuardMetricRegistrationError code E_GUARD_METRIC_CARDINALITY_EXCEEDED, carrying the guard id, the attempted count, and the limit, and the guard is left out of the metric family. The stable 12-character guard id derives from the guard digest.
pub fn guard_id_label_from_digest(digest: &str) -> String {
digest
.strip_prefix("sha256:")
.unwrap_or(digest)
.chars()
.take(12)
.collect()
}Two guards sharing their first 12 hex digits would collide, which stays negligible at 1,024 active guards. A fleet near the cap raises the limit with GuardMetricRegistry::with_max_guards(n) and keeps the full id length. The pool registry takes the same limit as a tenant ceiling through GuardPoolMetrics::with_max_tenants(n).
Stripping helpers
Two entries of the denylist are re-exported from chio_kernel::otel; ATTR_CHIO_REPLAY_RUN_ID and ATTR_CHIO_TENANT_ID are declared beside the array. A caller that pushes spans to a Prometheus-shaped sink of its own calls strip_denied_span_attributes before the push.
pub use denylist::{
denied_attribute_keys, is_denied_attribute, strip_denied_attributes,
strip_denied_batch_attributes, strip_denied_span_attributes, PROMETHEUS_DENIED_ATTRIBUTES,
};The receipt exporter crate
chio-otel-receipt-exporter turns OTLP trace ingress into signed receipts. The kernel host's serve mode runs its OTLP gRPC ingress listener when one is configured, and a Rust caller can embed the same two pieces:
OtlpGrpcIngress. Accepts OTLP/gRPC trace exports in a narrow Rust representation.BoundedOtlpGrpcIngressadds a queue with the drop counters the scrape reports.ReceiptStoreSink. Builds oneChioReceiptper OTLP span, signs it withReceiptStoreSinkConfig.signing_keypair, and appends it to the configuredArc<dyn ReceiptStore>.
The sink's public API in crates/observability/chio-otel-receipt-exporter/src/sink.rs is three methods: new(store, config), export_traces(&OtlpGrpcTraceExport) returning a ReceiptStoreSinkSummary, and receipt_for_span(&OtlpSpan) returning one ChioReceipt. Both fallible methods return OTelReceiptExportError.
use chio_otel_receipt_exporter::{ReceiptStoreSink, ReceiptStoreSinkConfig};
let config = ReceiptStoreSinkConfig {
signing_keypair, // Ed25519 keypair
policy_hash: "otel-receipt-exporter".into(),
default_capability_id: "otel-ingress".into(),
default_tool_server: "otel-collector".into(),
default_tool_name: "gen_ai.tool.call".into(),
tenant_id: Some("prod".into()),
};
let sink = ReceiptStoreSink::new(receipt_store, config);
let summary = sink.export_traces(&otlp_export)?;
println!("appended {} receipts", summary.appended_receipts);The five string defaults are the ones ReceiptStoreSinkConfig::new fills in, with tenant_id defaulting to None. Each receipt carries the sanitized attributes in a ToolCallAction and goes through the same ReceiptStore trait the kernel uses. The sink validates trace_id and span_id and calls strip_denied_attributes on every span before signing, so the canonicalized form is stable across re-export. A retried partial batch re-appends only its unappended receipts, with the same ids.
Span and log fields the dashboards expect
deploy/dashboards/README.md lists the fields every shipped dashboard expects on spans and log lines: chio.receipt.id, chio.tenant.id, chio.policy.ref, chio.verdict, chio.tee.mode, chio.deny.reason, chio.guard.outcome, provenance.otel.trace_id, provenance.otel.span_id, redaction_pass_id, and redaction_elapsed_micros.
Log levels and fields
tracing::trace!. Per-step evaluation state. Off in production.tracing::debug!. Per-call detail for local development. Off in production except while investigating.tracing::info!. Lifecycle: kernel started, policy reloaded, checkpoint written, revocation propagated.tracing::warn!. Recoverable degradation and fail-closed fallbacks: cache-miss escalation, a guard rejecting invalid config and constructing a deny-all fallback, external-guard call failures, advisory verdicts.tracing::error!. Fail-closed events that produceVerdict::Denythrough the fallback path: caught panics, poisoned mutexes, signing failures, an unreachable store.
Guards share no fixed field set beyond guard. Each attaches the fields that describe its decision, and many deterministic guards, forbidden-path among them, emit no tracing at all; their evidence lives in the receipt. Two representative call sites:
tracing::warn!(
guard = "code-execution",
module = %name,
"denying code execution: dangerous module detected"
);tracing::warn!(
guard = "content-review",
service = %service,
endpoint = %endpoint,
detected_categories = ?categories,
"content-review denied outbound message"
);The guard value identifies the component (code-execution, content-review, forbidden-path), never an individual call. Every other field (module, service, endpoint, code_len) is guard-specific, so expect no decision, path, or reason field on an arbitrary guard's events.
The shipped Grafana dashboards
deploy/dashboards/README.md documents five dashboards. All five expect the fields above and use the datasource variables DS_LOKI, DS_TEMPO, and DS_JAEGER, so import works across stacks. The import loop globs every *.json under deploy/dashboards/, so it also installs grafana/chio-perf.json (signing-queue blocking and OTel drop counters) and chio-pheromone-relay-observability.json (relay retry queue, dead letters, stale leases, bounded rejections).
loki/chio-tee.json
"Chio TEE Loki receipt view", on Loki. Four panels: TEE records by verdict, a TEE mode and verdict density heatmap, the raw log stream, and a receipt-to-trace lookup table (top 50 by receipt id, with policy ref, verdict, and deny reason). Filters by receipt id, tenant id, trace id, verdict, and TEE mode. See Confidential Node for the TEE signals.
loki/verdict-drift.json
"Chio replay verdict drift", on Loki. Filters chio-replay logs to event="verdict_drift" and renders a heatmap of from_verdict × to_verdict, a stacked time series of reason_delta, a top-100 drift records table, and a raw drift log stream.
jaeger/receipt-span-lookup.json
"Chio Jaeger receipt span lookup", on Jaeger. Pivots from receipt id, trace id, span id, tenant id, and tool name to traces. Four panels: trace search by tags, a table of receipt and span lookups, traces by verdict over time, and trace duration over time.
tempo/span-timeline.json
"Chio Tempo span timeline", on Tempo. The primary panel runs the TraceQL query from step 3 to fetch every trace that touched a receipt. Further panels: a span rows table, a GenAI tool-call rate time series, and span duration by verdict.
tempo/redaction-latency.json
"Chio Tempo redaction latency", on Tempo. Tracks redaction-pass latency by redaction_pass_id and chio.guard.outcome. Four panels: p95 latency, a latency distribution histogram, a redaction pass spans table, and span rate.
Recording rules
Burn-rate alerts fire on ratios, so the ratios are recorded first, in one group at a 30-second interval. clamp_min(..., 1) in the denominator keeps a quiet window from dividing by zero and reporting a false 100% error ratio.
groups:
- name: chio-sre-recording
interval: 30s
rules:
- record: chio:decision_latency:histogram_quantile_p95_5m
expr: |
histogram_quantile(
0.95,
sum by (surface, outcome, le) (rate(chio_kernel_decision_latency_seconds_bucket[5m]))
)
- record: chio:receipt_write_error_ratio_5m
expr: |
sum(rate(chio_receipt_write_total{outcome="error"}[5m]))
/
clamp_min(sum(rate(chio_receipt_write_total[5m])), 1)| record | Over |
|---|---|
chio:decision_ | chio_, by surface and outcome |
chio:receipt_ / _1h / _6h | chio_ |
chio:sidecar_ / _6h | chio_ |
chio:alert_ / _6h | chio_ |
chio:soc_ / _6h | chio_ |
chio:anchor_ | chio_, by witness and outcome |
chio:federation_ | chio_, by result |
chio:guard_ | chio_, by guard and verdict |
chio:alert_ | chio_, by route and outcome |
chio:receipt_ | max_over_time(chio_ |
The one-hour and six-hour pairs exist because each burn-rate alert requires both windows over budget at once. The five-minute receipt-write ratio and the two _5m gauge and latency rollups feed dashboards and SOC export rather than a paging rule.
Burn-rate SLO alerts
Each SLO uses two windows: the short one catches a fast burn, the long one suppresses flapping. The receipt-write rule in step 5 sets the shape. ChioSidecarErrorBudgetBurn1h uses it with a 0.5% budget at p2, and ChioAlertDispatchErrorBudgetBurn1h and ChioSocExportErrorBudgetBurn1h with 1% each. The 14.4 and 6 multipliers are the standard fast and slow burn factors, applied to whichever budget the SLO holds.
Missing-data and zero-tolerance alerts
A silent exporter looks identical to a healthy one on a ratio, so each burn-rate alert has an absent_over_time watchdog. Some conditions carry no budget at all and page on the first occurrence.
- alert: ChioFailOpenSuspected
expr: increase(chio_fail_open_suspected_total[5m]) > 0
for: 0m
labels:
severity: p0
notification_route: pagerduty
opsgenie: "true"
slo: fail-closed
annotations:
summary: Chio observed a suspected fail-open path
runbook: docs/operator-runbook/slo.mdThe whole pack, with its labels
Fourteen rules in one group. The labels are the handoff to the chio-siem and chio-wall PagerDuty and OpsGenie dispatch path: severity, notification_route, and slo are on every rule, and three rules carry no opsgenie label, so a dispatcher that keys on opsgenie alone would drop those three.
| Alert | Fires on | severity | notification_ | opsgenie |
|---|---|---|---|---|
Chio | 1h and 6h ratios both over 0.1% budget, for 2m | p1 | pagerduty | yes |
Chio | absent_ | p1 | pagerduty | yes |
Chio | both windows over 0.5% budget, for 2m | p2 | pagerduty | yes |
Chio | absent_ | p2 | pagerduty | yes |
Chio | both windows over 1% budget, for 2m | p1 | pagerduty | yes |
Chio | absent_ | p1 | pagerduty | yes |
Chio | both windows over 1% budget, for 5m | p2 | opsgenie | yes |
Chio | absent_ | p2 | opsgenie | yes |
Chio | any increase in chio_, for 0m | p0 | pagerduty | yes |
Chio | absent_ | p1 | pagerduty | yes |
Chio | any increase in chio_, for 2m | p1 | pagerduty | yes |
Chio | absent_ | p2 | pagerduty | no |
Chio | p95 revocation lag above 30 seconds, for 10m | p1 | pagerduty | yes |
Chio | absent_ | p2 | pagerduty | no |
Chio | chio_, for 15m | p2 | pagerduty | no |
Seven *MetricsMissing rules cover the four burn-rate SLOs and three of the four zero-tolerance rules; ChioReceiptCheckpointStale pages on its own threshold. Every rule carries a summary and a runbook annotation pointing at docs/operator-runbook/slo.md.
A third file, deploy/prometheus/chio-pheromone-relay-observability-rules.yml, defines the signal relay's operator alerts with the same severity, notification_route, and opsgenie labels: ChioPheromoneRelayDeadLetters, ChioPheromoneRelayRetryPressure, ChioPheromoneRelayStaleLeases, and ChioPheromoneRelayReplayStorm.
Source files
The facts above come from crates/products/chio-api-protect/src/proxy/router.rs, crates/guards/chio-wasm-guards/src/metrics.rs, crates/observability/chio-metrics-spec/src/runtime.rs, crates/observability/chio-otel-receipt-exporter/src/, deploy/dashboards/README.md, and deploy/prometheus/.