Skip to content

Metrics and Monitoring

This document describes the metrics and monitoring capabilities of the Continuum Router.

Table of Contents

Overview

The Continuum Router exposes Prometheus-compatible metrics for monitoring system health, performance, and usage patterns. The metrics system is designed to be:

  • Lightweight: Minimal performance overhead
  • Broad coverage: Covers HTTP, backend, routing, model, streaming, and cache subsystems
  • Production-ready: Includes cardinality limits and proper labeling
  • Easy to integrate: Works with standard Prometheus/Grafana setups

For restart-survival history without standing up a full Prometheus stack, see the Persistent Metrics Log. It snapshots the registry to a local SQLite store and exposes recent history via GET /admin/metrics/history.

Metrics answer how much and how often. For per-request causality, which backend was chosen, how many attempts a request took, and why each attempt ended the way it did, see Distributed Trace Export, which ships the router's own spans to an OpenTelemetry collector over OTLP.

Quick Start

1. Enable Metrics

Metrics are enabled by default. The metrics endpoint is available at /metrics:

# View metrics
curl http://localhost:9090/metrics

2. Configure Prometheus

Add the router as a target in your prometheus.yml:

scrape_configs:
  - job_name: 'continuum-router'
    static_configs:
      - targets: ['localhost:9090']
    scrape_interval: 15s

3. Import Grafana Dashboard

Import the provided dashboard from monitoring/grafana/dashboards/router-overview.json.

Configuration

Metrics configuration is done through the main config file:

metrics:
  enabled: true
  port: 9090
  path: /metrics
  max_model_labels: 1000
  max_backend_labels: 100
  cardinality_limits:
    max_models: 1000
    max_endpoints: 100
    max_error_types: 100
    enable_sampling: false
    sampling_rate: 1.0
  # External Prometheus is the durable store for this profile.
  persistence:
    enabled: false

Environment Variables

You can also configure metrics using environment variables:

# Enable/disable metrics
METRICS_ENABLED=true

# Change metrics endpoint
METRICS_ENDPOINT=/custom/metrics

# Enable optional metrics
METRICS_ENABLE_BODY_SIZE=true

Available Metrics

HTTP Metrics

Metric Type Description Labels
http_requests_total Counter Total number of HTTP requests method, endpoint, status_code, backend
http_request_duration_seconds Histogram Request latency method, endpoint, backend
http_active_connections Gauge Current active requests backend
http_request_size_bytes Histogram Request body size endpoint
http_response_size_bytes Histogram Response body size endpoint

The HTTP middleware reports backend="unknown" because backend selection happens inside handlers and not every route selects a backend. Per-backend traffic and load are available through the backend and prefix-routing metric families; client-supplied headers are never trusted for metric attribution.

Error and Retry Metrics

Metric Type Description Labels
errors_total Counter HTTP client/server errors observed by the request middleware error_type, backend
retry_attempts_total Counter Backend retry attempts after the initial attempt backend, attempt_number
retry_success_total Counter Requests that succeeded on a retry backend
retry_exhausted_total Counter Requests that failed after at least one backend retry backend
timeout_errors_total Counter Final backend or request timeout responses operation, backend

Circuit Breaker Metrics

Populated by ordinary proxy traffic when the metrics feature and a circuit_breaker are both enabled.

Metric Type Description Labels
circuit_breaker_state Gauge Current per-backend circuit state (0 Closed, 1 Open, 2 HalfOpen) backend
circuit_breaker_failures_total Counter Failures recorded against a backend circuit backend, error_type
circuit_breaker_successes_total Counter Successes recorded against a backend circuit backend
circuit_breaker_transitions_total Counter Circuit state transitions backend, from_state, to_state

Request Parameter Policy Metrics

Metric Type Description Labels
request_param_policy_total Counter Applied parameter actions and rejected policy decisions protocol, parameter, action, outcome
request_param_policy_composition_total Counter Local and Hub policy compositions protocol, source, outcome

Both families use closed, bounded labels. source is one of none, local, hub, or intersection; neither family exports request values, model ids, tier ids, key ids, or policy cursors.

Backend Metrics

Metric Type Description Labels
backend_healthy Gauge Backend health (1=healthy, 0=not healthy); URL label is redacted backend, url
backend_health_check_duration_seconds Histogram Active health-check duration backend
backend_health_check_failures_total Counter Failed active health checks backend
backend_current_load Gauge Live in-flight requests dispatched to the backend backend
backend_weight Gauge Configured load-balancing weight backend

Engine Statistics Metrics

Emitted only with engine_stats.enabled: true, and only for engine-stats-capable backend types (vllm, sglang, llamacpp, mlxcel, ollama, lmstudio). The poller reads each engine's own load or metrics endpoint (vLLM GET /metrics, SGLang GET /v1/loads with /get_load and /metrics fallbacks, llama.cpp GET /slots and GET /metrics selected via the /props capability booleans, Ollama GET /api/ps, LM Studio GET /api/v0/models) and normalizes the answer into one snapshot per backend.

Metric Type Description Labels
backend_engine_requests_running Gauge Requests the engine reports as executing backend
backend_engine_requests_waiting Gauge Requests the engine reports as queued backend
backend_engine_kv_cache_usage_ratio Gauge Engine KV cache usage fraction (0..1) backend
backend_engine_kv_tokens_capacity Gauge Engine KV cache token capacity backend
backend_engine_generation_tokens_per_second Gauge Engine-reported generation throughput backend
backend_engine_prefix_cache_hit_ratio Gauge Engine prefix-cache hit rate (0..1) backend
backend_engine_context_length Gauge Maximum context available to a single request (per-slot for llama.cpp) backend
backend_engine_slots_total Gauge Parallel processing slots the engine advertises backend
backend_engine_stats_scrape_success Gauge Most recent engine scrape succeeded (1) or failed (0) backend
backend_engine_stats_scrape_duration_seconds Gauge Duration of the most recent engine scrape backend
backend_engine_stats_age_seconds Gauge Snapshot age at router scrape time backend
backend_engine_prompt_tokens_total Counter Prompt tokens processed by the engine, mirrored with reset detection backend
backend_engine_generation_tokens_total Counter Generation tokens produced by the engine, mirrored with reset detection backend

Three representation rules keep the series honest:

  • Absent, not zero. A series for a figure the engine has no concept of is not emitted at all, decided statically per adapter. The one exception is SGLang /v1/loads, whose wire format omits zero-valued fields (msgspec omit_defaults); the adapter reads a missing number there as a truthful 0, so an idle SGLang backend keeps reporting backend_engine_requests_running 0 instead of vanishing.
  • A failing scrape makes one statement. On any scrape error the backend emits only backend_engine_stats_scrape_success 0; the other series disappear until a scrape succeeds, so a broken scrape can never be read as an idle engine. The router keeps the last snapshot internally (visible with growing staleness_seconds on the Admin API).
  • Multi-rank aggregation. An SGLang data-parallel engine reports one entry per DP rank; ranks merge into one snapshot by summing counts, capacities, and throughput and taking the maximum of usage fractions and hit rates (the saturated rank is the one that matters). The same rule applies when a Prometheus body carries multiple label sets for one series.

The two *_tokens_total counters apply reset detection (an engine value below the last observed one counts as a restart, and the new value is the delta since the restart). Reset detection is applied only to series the engine declares as counters; llama.cpp's /metrics mixes counters with gauges, and a gauge legitimately decreases.

A backend removed by hot reload loses its backend_engine_* series on the next scrape; a backend added by hot reload starts reporting within one polling interval. See engine_stats in config.yaml.example for the configuration surface, and GET /admin/backends/{name}/engine-stats for the JSON snapshot.

An engine's answer is treated as untrusted input, because a serving engine can be buggy or compromised independently of the router:

  • Confined reach. The poller fetches only the configured backend URL, or a metrics_url that resolves to the same host, and it follows no redirects. The same-host rule is checked at config load and again immediately before each fetch, and it fails closed when the backend URL does not parse. A metrics_url may not downgrade an https backend to plaintext http, because the backend API key is sent with the metrics fetch; allow_external_metrics_url widens the host rule and does not lift that one.
  • Nothing from the body reaches a log. Parse failures report the structural classification and the position, never the offending value, so no slice of an engine body (notably the llama.cpp /slots body) can reach the router log through a failed scrape. Transport failures report a credential-free URL.
  • Bounded intake. The response is read against max_body_bytes chunk by chunk, the Prometheus parser caps samples per body and labels per sample, and the only engine free text a snapshot keeps (model names and the engine version) is length-bounded and stripped of invisible control characters. Numeric aggregation saturates instead of overflowing.

Model Service Metrics

Metric Type Description Labels
model_cache_size Gauge Models in the aggregation cache
model_cache_hits_total Counter Model aggregation cache hits
model_cache_misses_total Counter Model aggregation cache misses
model_refresh_failures_total Counter Failed backend model fetches
model_refresh_duration_seconds Histogram Time for one whole model-list aggregation
model_backend_fetch_duration_seconds Histogram Time for one backend's model fetch backend
model_last_refresh_duration_seconds Gauge Duration of the most recent aggregation
model_fetch_attempts_total Counter Backend model fetch attempts, including retries
model_empty_responses_total Counter Refreshes that produced an empty model list
model_backends_unavailable_total Counter Refreshes where no backend returned models
model_rate_limit_exceeded_total Counter Model endpoint requests rejected by the rate limiter
model_transient_errors_total Counter Retryable per-backend fetch failures
model_permanent_errors_total Counter Non-retryable per-backend fetch failures
model_stale_while_revalidate_total Counter Requests served from a stale cache during a refresh
model_coalesced_requests_total Counter Requests coalesced onto an in-flight aggregation
model_background_refreshes_total Counter Backend fan-outs started by background revalidation, one per aggregation pass; a pass superseded while in flight (issue #1548) is retried in place and each retry counts again, so this stays a fan-out count while model_background_refresh_successes_total and model_background_refresh_failures_total advance once per revalidation (issue #1552)
model_background_refresh_successes_total Counter Background revalidations that ended in a stored result, counted once per revalidation however many passes it ran
model_background_refresh_failures_total Counter Background revalidations that failed, counted once per revalidation
model_singleflight_lock_acquired_total Counter Aggregation-lock acquisitions for singleflight
model_superseded_aggregations_total Counter Refreshes stored as expired rather than fresh because the pool membership or the cache invalidation epoch moved while the fan-out was in flight (issue #1548), including a membership move detected by the post-store re-check (issue #1552); each one is followed by an immediate re-aggregation, except after three consecutive supersessions, when the next revalidation waits a 2 s re-arm interval

Diagnosing a slow model list

model_refresh_duration_seconds tells you that an aggregation was slow; model_backend_fetch_duration_seconds tells you which backend made it slow, because the model-list fan-out is concurrent and the total is therefore the slowest single backend.

# Which backend dominates the refresh, 95th percentile over 5 minutes
histogram_quantile(0.95, sum by (backend, le) (rate(model_backend_fetch_duration_seconds_bucket[5m])))

Both histograms bucket up to 60 seconds. A backend exceeding the per-attempt request_timeout also emits one WARN log line naming the backend, its duration, and its attempt count, so the same finding is available without Prometheus and without raising the global log level to DEBUG:

WARN Slow model fetch from backend backend="claude-bedrock" duration_ms=15503 attempts=3 threshold_ms=5000 outcome="error"

model_backend_fetch_duration_seconds carries one series per backend name. Backend names are operator-defined and bounded by the configuration, so cardinality is normally small; a deployment that registers backends dynamically (AppProxy replicas) should expect one series per distinct replica name.

Routing telemetry is exported by the live KV cache and smart routing families below. Unsupported members of the former generic routing and model-service collectors are not registered.

Active streaming response bodies contribute to http_active_connections. The router does not currently export generic streaming duration or post-response error metrics; mid-stream fallback has the dedicated live metrics below.

Mid-Stream Fallback Metrics

These metrics are emitted when the mid-stream fallback feature is enabled (streaming.mid_stream_fallback.enabled: true).

Metric Type Description Labels
streaming_fallback_total Counter Total streaming fallback attempts, counting the pre-stream hops the mid-stream arm performs before its SSE response is committed reason
streaming_fallback_success_total Counter Successful mid-stream fallback recoveries original_backend, fallback_backend
streaming_fallback_accumulated_tokens Histogram Estimated tokens accumulated before fallback outcome (success, failure)

Reason Label Values for streaming_fallback_total

Value Description
timeout Backend inactivity timeout exceeded
connection_error TCP/TLS connection error
stream_read_error Error reading bytes from stream
stream_ended_unexpectedly Stream closed without [DONE] marker
too_many_stream_errors Consecutive error event threshold reached
other Other failure reason

Key PromQL Queries

# Mid-stream fallback rate
rate(streaming_fallback_total[5m])

# Fallback recovery success rate
sum(rate(streaming_fallback_success_total[5m])) /
sum(rate(streaming_fallback_total[5m]))

# Median accumulated tokens at fallback trigger
histogram_quantile(0.5, rate(streaming_fallback_accumulated_tokens_bucket[5m]))

Responses Bridge Metrics

Counts /v1/chat/completions requests the router dispatched to the upstream /v1/responses endpoint instead. Always registered; the counter simply stays at zero when nothing is bridged.

Metric Type Description Labels
responses_bridge_total Counter Chat Completions requests dispatched through /v1/responses reason

Reason Label Values for responses_bridge_total

Value Description
responses_only_model The model is flagged responses_only in metadata, or the backend serves only the Responses API (a Codex/ChatGPT-subscription backend). Every request for it is bridged.
tools_with_reasoning The model is served on Chat Completions, and only this request shape is bridged: a non-empty tools array combined with a reasoning effort upstream refuses there. See Reasoning Effort.

The two mean different things operationally. responses_only_model is a standing property of the deployment and its rate tracks traffic to those models. tools_with_reasoning is a property of the traffic itself: it tells you how much of your agent traffic is paying the translation round-trip, and it drops to zero on its own once OpenAI lifts the restriction and the metadata flag is cleared.

# Share of bridged traffic that is the conditional tools bridge
sum(rate(responses_bridge_total{reason="tools_with_reasoning"}[5m])) /
sum(rate(responses_bridge_total[5m]))

Model Experiment Metrics

Per-variant attribution for model experiments. A dedicated family, so existing request, latency, and token series keep their label sets. Label values come from model_experiments configuration (at most 32 experiments with 16 variants each) or from closed sets.

Metric Type Description Labels
model_experiment_requests_total Counter Requests resolved through an experiment experiment, variant, status (success, client_error, server_error), assignment (sticky, random)
model_experiment_request_duration_seconds Histogram Time to response headers experiment, variant
model_experiment_tokens_total Counter Prompt and completion tokens experiment, variant, kind
model_experiment_fallbacks_total Counter Requests served by a cross-model fallback hop (fallback: cross_variant) experiment, variant
model_experiment_reserved_refusals_total Counter Requests refused for naming a variant model that a hide_variant_models experiment reserves experiment

Fallback Metrics

Metric Type Description Labels
fallback_attempts_total Counter Total fallback attempts original_model, fallback_model, backend
fallback_success_total Counter Successful fallbacks original_model, fallback_model, backend
fallback_exhausted_total Counter Exhausted fallback chains original_model
cross_provider_fallback_total Counter Cross-provider fallbacks original_model, fallback_model, original_provider, fallback_provider
fallback_duration_seconds Histogram Fallback operation duration original_model, success
fallback_dial_bound_saturated_total Counter Fallback hop dials that waited on the per-backend dial bound (fallback.fallback_policy.max_concurrent_dials_per_backend) until the wait cap expired and failed the hop for that backend backend

Response Cache Metrics

Metric Type Description Labels
continuum_response_cache_requests_total Counter Cache lookups by result result (hit, miss, skip)
continuum_response_cache_entries Gauge Current number of cached entries --
continuum_response_cache_size_bytes Gauge Approximate cache memory usage --
continuum_response_cache_evictions_total Counter LRU evictions --
continuum_response_cache_hit_rate Gauge Rolling cache hit rate (0.0--1.0) --
continuum_cache_backend_type Gauge Active cache backend (1 = active) backend (memory, redis)

The eviction counter is exact for the in-memory LRU store. Redis and S3 perform expiry or eviction server-side and do not expose an eviction event to the router, so those backends do not fabricate a value.

Redis Cache Backend Metrics

These metrics are populated when the Redis cache backend is active (backend: redis).

Metric Type Description Labels
continuum_cache_redis_connections_active Gauge Active Redis connections in the pool --
continuum_cache_redis_connections_idle Gauge Idle Redis connections in the pool --
continuum_cache_redis_latency_seconds Histogram Redis operation latency operation (get, set, delete)
continuum_cache_redis_errors_total Counter Redis errors by type type (connection, timeout, other)
continuum_cache_fallback_active Gauge Whether in-memory fallback is active (0 or 1) --

KV Event Consumer Metrics

These metrics are populated when KV event consumers are active (src/infrastructure/kv_index/). vLLM, SGLang, and TensorRT-LLM listeners may use router salt echo to preserve the same prefix identity without detokenizing every block event. All backend label values are sanitized to prevent cardinality explosion.

Metric Type Description Labels
continuum_kv_event_received_total Counter KV cache events received from each backend backend
continuum_kv_event_processed_total Counter KV cache events successfully forwarded via channel backend
continuum_kv_event_dropped_total Counter KV cache events dropped due to backpressure backend
continuum_kv_consumer_connected Gauge Whether the KV event consumer is connected (1 = connected, 0 = disconnected) backend
continuum_kv_consumer_reconnects_total Counter Total reconnection attempts for each backend consumer backend

Prefix Routing Metrics

These metrics track prefix-aware sticky routing decisions and backend distribution.

Metric Type Description Labels
continuum_prefix_routing_requests_total Counter Total prefix routing decisions by strategy type strategy (prefix_hash, overflow, fallback, unknown)
continuum_prefix_routing_backend_distribution Gauge In-flight requests per backend (for load balancing) backend
continuum_prefix_routing_prefix_cardinality Gauge Approximate number of unique prefix keys seen --

continuum_prefix_routing_requests_total is recorded once per backend selection and only when the PrefixAwareHash strategy arm ran, so it stays at zero under any other selection_strategy. The three labels map to the ring walk directly:

  • prefix_hash: the prefix-hashed owner took the request, either because it was under the CHWBL load cap or because every backend was at the cap and the full-lap fallback kept the owner to preserve prefix locality.
  • overflow: the owner was at or over the load cap and a later node clockwise on the ring took the request.
  • fallback: the request carried no prefix key, so the model name was hashed and the ring was walked with no load cap. A steady 100% fallback rate almost always means prefix_routing.enabled is false.

unknown is a sanitizer output, not a routing path: it appears only if a label outside the allowlist ever reaches the recorder.

Key PromQL Queries

# Prefix routing hit rate (% of requests using prefix hash vs fallback)
sum(rate(continuum_prefix_routing_requests_total{strategy="prefix_hash"}[5m])) /
sum(rate(continuum_prefix_routing_requests_total[5m]))

# Overflow rate (CHWBL load balancing activations)
rate(continuum_prefix_routing_requests_total{strategy="overflow"}[5m])

# Backend load distribution (should be roughly even)
continuum_prefix_routing_backend_distribution

KV Cache Index Metrics

These metrics track the KV cache index subsystem including index state, query performance, routing decisions, and overlap scoring.

Metric Type Description Labels
continuum_kv_index_entries Gauge Current number of entries in the KV cache index --
continuum_kv_index_events_total Counter KV cache index mutation events (created/evicted) backend, type (created, evicted)
continuum_kv_index_query_latency_seconds Histogram Latency of KV index query operations --
continuum_kv_index_routing_decisions_total Counter KV-aware routing decisions by outcome decision (kv_aware, fallback)
continuum_kv_index_overlap_score Histogram Winning combined score of each scorer-decided selection --
continuum_kv_index_event_source_status Gauge Event source connection status (1 = connected, 0 = disconnected) backend, status
routing_engine_load_decisions_total Counter Engine-load-aware routing decisions (issue #1447) backend, reason (engine_load, stale_fallback, hysteresis_hold, admission_reject)

routing_engine_load_decisions_total is recorded once per scoring pass while routing.engine_load.enabled holds (plus once per refused selection when the admission hint trips): engine_load means the engine-load term actively ranked candidates and backend names the engine-preferred one, stale_fallback means no candidate had a fresh engine snapshot so the pass matched the base scoring (backend="none"), hysteresis_hold means fresh data existed but the waiting_requests spread stayed inside the balance thresholds, and admission_reject means the saturation admission hint refused selection because every healthy candidate reported kv_cache_usage above the threshold (backend="none"). Backend label values pass through the same sanitizer as every backend-labeled series. The gate, load normalization, cached result, and backend label are scoped to the request's exact live candidate set, so an engine excluded by model visibility, health, retry state, or per-key permissions cannot influence or be named by that scoring decision.

Both routing labels are recorded at the same seam, once per selection: kv_aware when the KV overlap scorer won and its backend was forwarded to, and fallback when the scorer ran with a prefix key but no backend cleared the pool's compiled-in combined-score threshold of 0.3, so the configured selection_strategy decided instead. A selection the scorer never attempted (no scorer registered, or no prefix key) records neither, so the two counters sum to the number of scored selections rather than to total traffic. The histogram observes the winning combined score of a kv_aware decision, which is the full weighted sum of overlap, load, and health, not the overlap term on its own.

Key PromQL Queries

# KV-aware routing ratio
sum(rate(continuum_kv_index_routing_decisions_total{decision="kv_aware"}[5m])) /
sum(rate(continuum_kv_index_routing_decisions_total[5m]))

# Average overlap score for routed requests
histogram_quantile(0.5, rate(continuum_kv_index_overlap_score_bucket[5m]))

# KV index query P99 latency
histogram_quantile(0.99, rate(continuum_kv_index_query_latency_seconds_bucket[5m]))

# Event source connection health
continuum_kv_index_event_source_status{status="connected"}

Smart Routing Metrics

These metrics cover the smart routing pipeline, including the LLM-based classifier.

Classification and Routing

Metric Type Description Labels
smart_routing_classifications_total Counter Total classifications performed complexity, domain, classifier_type
smart_routing_decisions_total Counter Total routing decisions made source_model, target_model, policy, tier
smart_routing_classifier_duration_seconds Histogram Classifier latency classifier_type
smart_routing_policy_no_match_total Counter Requests with no matching policy -
smart_routing_tier_no_model_total Counter Policy matched but no model available in tier tier

Load Management

Metric Type Description Labels
smart_routing_load_state Gauge Current load state: 0=Normal, 1=Warning, 2=Critical -
smart_routing_tier_degradation_total Counter Routing degraded due to load load_state
smart_routing_load_transitions_total Counter Load state transitions from_state, to_state

LLM Classifier

Metric Type Description Labels
smart_routing_llm_classifier_calls_total Counter Total LLM classifier invocations -
smart_routing_llm_classifier_cache_hits_total Counter Classification results served from cache -
smart_routing_llm_classifier_duration_seconds Histogram End-to-end LLM classification latency (buckets: 50ms–5s) -
smart_routing_llm_classifier_fallbacks_total Counter Times the LLM result was discarded and rule-based result used -
smart_routing_llm_classifier_parse_errors_total Counter Response parse failures before retry -
smart_routing_llm_classifier_retries_total Counter Retry attempts after initial parse failure -

Aggregate and Operational

Metric Type Description Labels
smart_routing_requests_total Counter Total smart-routed requests source_model, target_model, policy, load_state
smart_routing_tier_usage_total Counter Tier usage distribution tier, domain
smart_routing_cost_estimate_total Counter Estimated cost from tier optimization tier
smart_routing_policy_evaluations_total Counter Policy evaluation frequency policy_name, result
smart_routing_model_availability Gauge Available models per tier model, tier

Key PromQL Queries

# Smart routing request rate by policy
rate(smart_routing_requests_total[5m])

# Tier usage distribution
sum by(tier) (rate(smart_routing_tier_usage_total[5m]))

# LLM classifier cache hit rate
rate(smart_routing_llm_classifier_cache_hits_total[5m]) /
rate(smart_routing_llm_classifier_calls_total[5m])

# LLM classifier P95 latency
histogram_quantile(0.95, rate(smart_routing_llm_classifier_duration_seconds_bucket[5m]))

# LLM classifier fallback rate (reliability indicator)
rate(smart_routing_llm_classifier_fallbacks_total[5m]) /
rate(smart_routing_llm_classifier_calls_total[5m])

# Fraction of requests classified by LLM vs rule-based
rate(smart_routing_classifications_total{classifier_type="llm_based"}[5m]) /
rate(smart_routing_classifications_total[5m])

# Policy evaluation success rate
sum by(policy_name) (rate(smart_routing_policy_evaluations_total{result="matched"}[5m]))

Business Metrics

Metric Type Description Labels
model_usage_total Counter Successful model request count model, backend
model_tokens_processed Counter Tokens reported by successful model responses model, type (input, output)

Guardrail Metrics

Exported when guardrails are configured and the metrics feature is enabled. Every guardrail decision is recorded so operators can observe what a policy does (or would do, in monitor mode) before and after enforcement.

Metric Type Description Labels
guardrail_checks_total Counter Per-provider checks by stage and verdict result stage, provider, result
guardrail_blocks_total Counter Block verdicts by stage, provider, and category stage, provider, category
guardrail_check_duration_seconds Histogram Per-provider check latency in seconds stage, provider
guardrail_errors_total Counter Provider errors (timeout / hard failure) provider, kind
guardrail_fail_open_total Counter Provider failures resolved fail-open (request allowed) provider
guardrail_fail_closed_total Counter Provider failures resolved fail-closed (request blocked) provider
guardrail_degraded_total Counter Provider checks completed at reduced fidelity, such as PII built-in-only fallback when the optional external recognizer is unavailable provider, kind
guardrail_verdicts_total Counter Aggregated verdict per request after applying mode semantics stage, mode, result
guardrail_stream_buffer_cap_trips_total Counter Streams that reached the streaming output gate's 4 MiB retained-byte cap, counted once per stream strategy, outcome

Label values:

  • stage is input, output, or streaming.
  • result is allow, block, transform, or flag.
  • kind is timeout or error.
  • mode is monitor or enforce. Because guardrail_verdicts_total carries mode, monitor-mode verdicts are visible even though they never gate a request, which is what makes the monitor-then-enforce rollout observable.
  • provider is a configured provider name, or one of the two reserved match-list labels: match_list_deny when a guardrails.deny rule decided the check, match_list_allow when an allow rule did. A deny-list block also carries category="deny_list" on guardrail_blocks_total. See Allow and deny lists.
  • strategy is the strategy in effect when the cap tripped (buffer_full / chunked / monitor); outcome is how output checking continued past it (degraded_chunked / compacted / truncated). See the buffer cap section for what each outcome means for the safety guarantee. strategy="monitor" is not a safety event: it means a monitor-mode stream was long enough that only its first 4 MiB was observed.

Key PromQL Queries

# What would be blocked, broken down by category (monitor-mode tuning)
sum by (category) (rate(guardrail_blocks_total[1h]))

# Block rate per stage after enforcement
sum by (stage) (rate(guardrail_verdicts_total{result="block", mode="enforce"}[5m]))

# Provider error rate (timeouts vs hard failures)
sum by (provider, kind) (rate(guardrail_errors_total[5m]))

# P95 guardrail check latency per provider
histogram_quantile(0.95, sum by (le, provider) (rate(guardrail_check_duration_seconds_bucket[5m])))

For the full guardrail guide (concepts, providers, configuration, and the threshold-tuning workflow), see Guardrails.

Per-API-Key LLM Token Usage

The router publishes a per-API-key breakdown of LLM token consumption so operators can answer questions like "which key consumed the most completion tokens last hour?" or "how many prompt tokens did team X spend on model Y today?". This data is independent of the model_tokens_processed aggregate counter and supports capacity planning, fair-use enforcement, and external cost attribution.

Metric Definition

Metric Type Description Labels
llm_tokens_total Counter LLM tokens consumed per request api_key_id, model, backend, kind
api_key_info Gauge (constant 1) Info-metric exposing configured API-key annotations as labels api_key_id, plus the configured annotation allowlist

kind is one of:

  • prompt — tokens in the upstream request prompt
  • completion — tokens in the upstream response completion

Both OpenAI-compatible (prompt_tokens / completion_tokens) and Anthropic (input_tokens / output_tokens) response shapes are normalized into the same counter. The router also injects stream_options.include_usage=true on OpenAI-compat streaming requests so usage data arrives in the final SSE chunk regardless of client behavior.

api_key_id Derivation

api_key_id is never the raw API key. The router derives a stable, non-reversible identifier in this priority order:

  1. If the request's bearer token matches a configured API-key entry, the entry's id field is used (e.g., key-production-1).
  2. Otherwise, the router computes SHA-256 over the raw token and uses the first 12 hex characters prefixed with k_ (e.g., k_3f5a7c9b1e2d).
  3. If no token is presented, the literal value anonymous is used.

All label values flow through the existing CardinalityManager so a runaway/rotating-key attack cannot exhaust Prometheus series.

Annotation Labels and api_key_info

Each configured API key may carry an optional free-form annotations: { key: value } map. Operators declare which annotation keys become Prometheus labels via the global metrics.annotation_labels allowlist; everything else stays internal.

Configuration schema (under the existing api_keys block):

api_keys:
  api_keys:
      - key: "${API_KEY_1}"
        id: "key-production-1"
        user_id: "user-admin"
        organization_id: "org-main"
        annotations:
            email: "ops@example.com"
            team: "platform"
            environment: "prod"
            owner: "alice"

metrics:
    enabled: true
    annotation_labels: [email, team]            # Allowlist of label keys

Reserved annotation keys (recommended canonical names, not enforced): email, uuid, owner, team, environment. Operators may add custom keys.

When metrics.annotation_labels is non-empty, the router publishes api_key_info{api_key_id, email, team, ...} = 1 once per known key. Use PromQL joins to project the metadata onto llm_tokens_total without bloating its label set:

# Tokens per email (sums prompt + completion, last 24h)
sum by (email) (
  increase(llm_tokens_total[24h])
  * on (api_key_id) group_left(email) api_key_info
)

Cardinality and Hot-Reload

  • api_key_id cardinality is bounded at 1000 by default.
  • Hot-reload of API-key annotations is supported via the existing config-reload pipeline. The api_key_info info-metric is republished atomically on every reload; counter values for llm_tokens_total are never reset.
  • The label set on api_key_info (i.e., the contents of annotation_labels) is frozen at startup. Adding or removing keys from the allowlist requires a restart — Prometheus does not allow renaming labels on a registered metric.

Example PromQL Queries

# Total prompt tokens consumed per API key in the last hour
sum by (api_key_id) (
  increase(llm_tokens_total{kind="prompt"}[1h])
)
# Top 10 keys by completion tokens in the last 24h
topk(10,
  sum by (api_key_id) (
    increase(llm_tokens_total{kind="completion"}[24h])
  )
)
# Tokens grouped by team (requires team in annotation_labels)
sum by (team) (
  increase(llm_tokens_total[24h])
  * on (api_key_id) group_left(team) api_key_info
)
# Combined prompt+completion rate per model (tokens/sec)
sum by (model) (rate(llm_tokens_total[5m]))
# Per-key consumption by backend (useful for cost attribution)
sum by (api_key_id, backend) (
  increase(llm_tokens_total[24h])
)

Grafana Panel Example

A simple Grafana stat panel showing the top 10 teams by completion tokens over the last 24 hours:

{
    "title": "Top 10 teams by completion tokens (24h)",
    "type": "stat",
    "targets": [
        {
            "expr": "topk(10, sum by (team) (increase(llm_tokens_total{kind=\"completion\"}[24h]) * on (api_key_id) group_left(team) api_key_info))",
            "legendFormat": "{{team}}"
        }
    ],
    "options": {
        "reduceOptions": {
            "values": false,
            "calcs": ["lastNotNull"]
        }
    }
}

For tracking spend trends, pair this with a time-series panel using rate(llm_tokens_total[5m]) grouped by team or model.

Verification Steps

After enabling the feature:

  1. Issue a chat-completion request with a configured API key.
  2. Scrape /metrics and confirm llm_tokens_total{...} and api_key_info{...} series appear.
  3. For streaming, verify the counter still increments — usage is captured from the final SSE chunk. The router injects stream_options.include_usage=true automatically for OpenAI-compat backends so this works regardless of client behavior.
  4. Inspect /metrics cardinality on a typical workload (e.g., wc -l < /metrics) to confirm no regression versus the prior baseline.

Integration

Prometheus Configuration

Complete Prometheus configuration example:

global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'continuum-router'
    static_configs:
      - targets: ['router1:9090', 'router2:9090']
    metric_relabel_configs:
      # Drop high-cardinality metrics if needed
      - source_labels: [__name__]
        regex: 'http_request_duration_seconds_bucket'
        action: drop

Kubernetes Integration

For Kubernetes deployments, use ServiceMonitor:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: continuum-router
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: continuum-router
  endpoints:
    - port: metrics
      interval: 15s
      path: /metrics

The Helm chart can render this object with serviceMonitor.enabled=true when the Prometheus Operator CRD is installed. For clusters without the operator, the repository also ships a standalone Prometheus Kustomize bundle with Kubernetes endpoint discovery and the checked-in alert rules:

kubectl apply -k monitoring/prometheus
kubectl -n monitoring rollout status deployment/continuum-router-prometheus
kubectl -n monitoring port-forward service/continuum-router-prometheus 9090:9090

The standalone profile requests a 10 GiB ReadWriteOnce PersistentVolumeClaim with 15-day retention. Confirm the default StorageClass and resize the claim for expected series volume, or use a managed Prometheus service for highly available monitoring. Review its cluster-wide discovery RBAC and NetworkPolicy before applying it.

Grafana Dashboard

monitoring/grafana/dashboards/router-overview.json contains these panels:

  • Request rate
  • Error rate
  • P95 latency
  • Backend health
  • Request rate by endpoint
  • Response-time percentiles
  • Model usage distribution
  • Current in-flight load across backends
  • Engine requests (running / waiting), from engine_stats (issue #1446)
  • Engine KV cache usage, from engine_stats (issue #1446)
  • Engine-load routing decisions by reason, from routing.engine_load (issue #1447)

To import it:

  1. Open Grafana.
  2. Select Dashboards → Import.
  3. Upload monitoring/grafana/dashboards/router-overview.json.
  4. Select the Prometheus data source and import.

Alerting

Pre-configured alert rules are available in monitoring/prometheus/alerts.yml:

Critical Alerts

{% raw %}

- alert: HighErrorRate
  expr: sum(rate(http_requests_total{status_code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.05
  for: 5m
  annotations:
    summary: "High error rate: {{ $value | humanizePercentage }}"

Warning Alerts

{% raw %}

- alert: HighLatency
  expr: histogram_quantile(0.95, http_request_duration_seconds) > 1
  for: 5m
  annotations:
    summary: "P95 latency above 1s: {{ $value | humanizeDuration }}"

- alert: TimeoutErrors
  expr: sum(rate(timeout_errors_total[5m])) > 0.1
  for: 10m
  annotations:
    summary: "Frequent timeout errors: {{ $value }} per second"

Examples

Query Examples

Request Rate by Status

sum(rate(http_requests_total[5m])) by (status)

P95 Latency by Endpoint

histogram_quantile(0.95, 
  sum(rate(http_request_duration_seconds_bucket[5m])) by (endpoint, le)
)

Backend Load Overview

backend_current_load

Model Usage Ranking

topk(10, sum(rate(model_usage_total[1h])) by (model))

Error Rate Percentage

sum(rate(http_requests_total{status_code=~"5.."}[5m])) /
sum(rate(http_requests_total[5m])) * 100

Programmatic Access

You can also access metrics programmatically:

import requests
from prometheus_client.parser import text_string_to_metric_families

# Fetch metrics
response = requests.get('http://localhost:9090/metrics')
metrics = text_string_to_metric_families(response.text)

# Process metrics
for family in metrics:
    for sample in family.samples:
        if sample.name == 'http_requests_total':
            print(f"Endpoint: {sample.labels['endpoint']}, Count: {sample.value}")

Custom Metrics Collection

#!/bin/bash
# Collect metrics every 30 seconds and save to file

while true; do
  timestamp=$(date +%s)
  curl -s http://localhost:9090/metrics > "metrics_${timestamp}.txt"
  sleep 30
done

Best Practices

1. Label Cardinality

Keep label cardinality low to prevent metric explosion:

# Good: Low cardinality
labels:
  status: "200"  # ~5 possible values
  method: "GET"  # ~7 possible values

---

# Bad: High cardinality
labels:
  user_id: "12345"  # Unbounded
  request_id: "abc-123"  # Unique per request

2. Metric Naming

Follow Prometheus naming conventions:

  • Use snake_case
  • Include units in metric names (_seconds, _bytes, _total)
  • Use standard prefixes (http_, backend_, model_)

3. Dashboard Design

  • Group related metrics together
  • Use appropriate visualization types (gauge for current values, graph for time series)
  • Include both absolute values and rates
  • Set reasonable refresh intervals (15-30s for real-time, 1-5m for historical)

4. Alert Configuration

  • Use appropriate evaluation periods (for: 5m to avoid flapping)
  • Include context in alert descriptions
  • Set up alert routing based on severity
  • Test alerts in staging before production

5. Performance Considerations

  • Disable optional metrics if not needed
  • Use recording rules for complex queries
  • Implement proper metric retention policies
  • Consider using remote storage for long-term retention

6. Security

  • Protect metrics endpoint if sensitive data is exposed
  • Use TLS for Prometheus scraping in production
  • Implement authentication for Grafana dashboards
  • Audit metric access logs

Troubleshooting

Metrics Not Appearing

  1. Check if metrics are enabled in configuration
  2. Verify the metrics endpoint is accessible
  3. Check Prometheus target status
  4. Review router logs for metric initialization errors

High Memory Usage

  1. Review cardinality limits
  2. Check for unbounded labels
  3. Reduce histogram buckets if needed
  4. Enable metric expiration

Incorrect Values

  1. Verify metric types (counter vs gauge)
  2. Check aggregation functions
  3. Review label selectors
  4. Validate time ranges

Additional Resources