Skip to main content
Version: Next

Router Observability

The Kthena router exposes three complementary observability surfaces:

  • Prometheus metrics for alerting, dashboards, and capacity analysis
  • When enabled, one access-log record for each /v1/* request
  • Loopback-only configuration-dump and pprof endpoints for live diagnosis

This guide describes the current router binary and the Helm defaults. Commands assume the release is installed in kthena-system; substitute the actual release namespace when it differs.

Endpoint map

The Helm chart uses three listeners. Health routes remain on the inference listener, metrics use a dedicated pod listener, and debug routes use a separate loopback-only listener.

ListenerDefaultEndpointsExposure
RouterContainer port 8080; Service port 80/healthz, /readyz, inference APIsThe kthena-router LoadBalancer Service
MetricsContainer and Service port 9090/metricsThe kthena-router-metrics ClusterIP Service and pod network
Debuglocalhost:15000/debug/config_dump/*, /debug/pprof/*Pod loopback only; no Service port

The metrics path is fixed at /metrics. Set networking.kthenaRouter.metrics.port to change its port. Metrics are not served on the public inference listener unless networking.kthenaRouter.metrics.exposeOnRouterPort is explicitly enabled for legacy compatibility.

The dedicated metrics listener binds to all pod interfaces and serves unauthenticated plain HTTP, even when TLS is enabled on the inference listener. A ClusterIP limits exposure outside the cluster but is not an authorization boundary. Use a NetworkPolicy when metrics must be restricted to monitoring workloads.

Verify the listeners

First, confirm that the router and both Services exist:

kubectl get -n kthena-system \
deployment/kthena-router \
service/kthena-router \
service/kthena-router-metrics

Then start each required port-forward in a separate terminal. These commands bind to local loopback by default:

# Dedicated metrics listener.
kubectl port-forward -n kthena-system service/kthena-router-metrics 9090:9090

# Debug listener. Port-forwarding reaches localhost inside the selected pod's
# network namespace even though no Kubernetes Service publishes this port.
kubectl port-forward -n kthena-system deployment/kthena-router 15000:15000

# Inference listener, for health checks without using the LoadBalancer address.
kubectl port-forward -n kthena-system service/kthena-router 8080:80

The examples below assume the chart's default ports. If networking.kthenaRouter.metrics.port or networking.kthenaRouter.debugPort was changed, use the configured remote port in the corresponding port-forward.

Metrics

The tables below list every Kthena-owned router metric family registered by the current binary. Histograms additionally expose Prometheus _bucket, _sum, and _count series. Standard Go runtime, process, and Prometheus handler metrics are also present; those collector-provided families can vary with dependency versions.

Some metrics appear only after the corresponding feature or request path has been exercised.

Configure Prometheus scraping

The chart creates the kthena-router-metrics Service but does not install a Prometheus Operator dependency or create a ServiceMonitor. If Prometheus Operator is already installed, the repository provides an optional example:

kubectl get customresourcedefinition servicemonitors.monitoring.coreos.com

# From the repository root, first update the namespace and labels in this file
# to match your Kthena release and Prometheus serviceMonitorSelector.
kubectl apply -f examples/observability/router-servicemonitor.yaml

The example selects the Service's named metrics port and /metrics path. If Prometheus runs without the Operator, configure its Kubernetes service discovery to scrape that same port and path. In either case, verify the target in Prometheus and check the endpoint directly when discovery fails:

curl --fail --show-error --silent http://localhost:9090/metrics \
| grep '^kthena_router_active_requests '

Grafana dashboards

Import examples/observability/kthena-router-overview-dashboard.json into Grafana and select the Prometheus data source that scrapes the Router. The dashboard provides the primary operational view of request traffic, errors, latency, prefill and decode phases, token throughput, concurrency, queues, rate limits, and routing failures.

The backend-load panel displays active upstream requests. It is not a saturation percentage because the Router does not know each backend's capacity. Combine it with runtime-specific capacity or utilization metrics when diagnosing backend saturation.

kthena-router-overview-dashboard

The cache-aware score-plugin dashboard remains a separate specialist view at examples/observability/kthena-router-score-plugins-dashboard.json.

kthena-router-prefix-cache-dashboard kthena-router-kvcache-aware-dashboard

Requests, traffic, and rate limiting

MetricTypeLabelsDescription
kthena_router_requests_totalCountermodel, path, status_code, error_type, model_route, backend_type, backend_name, upstream_modelCompleted authenticated inference requests whose body and model field were parsed
kthena_router_request_duration_secondsHistogrammodel, path, status_code, model_route, backend_type, backend_name, upstream_modelHandler latency after body and model parsing for those recorded requests
kthena_router_request_prefill_duration_secondsHistogrammodel, path, status_codePrefill latency for prefill/decode-disaggregated requests
kthena_router_request_decode_duration_secondsHistogrammodel, path, status_codeDecode latency for prefill/decode-disaggregated requests
kthena_router_tokens_totalCountermodel, path, token_type, model_route, backend_type, backend_name, upstream_modelInput or output tokens; token_type is input or output
kthena_router_rate_limit_exceeded_totalCountermodel, limit_type, pathRequests rejected by input-token, output-token, or request rate limits
kthena_router_active_requestsGaugenoneRequests currently inside the inference handler, including parse failures and model-list requests
kthena_router_active_downstream_requestsGaugemodelActive inference requests after the body and model field were parsed
kthena_router_active_upstream_requestsGaugemodel_server, model_route, backend_type, backend_name, upstream_modelActive router-to-backend requests

The request counter and duration histogram are created only after authentication and after the request body and model field have been parsed. They therefore exclude authentication rejections, parse failures, and GET /v1/models; use access logs or HTTP-layer telemetry when those requests must be counted. The model label is the requested model when the router's store recognizes it and unknown otherwise. The path label is the URL path without the query string.

Input token values come from the Router's pre-dispatch tokenizer and are also used for input rate limiting. Output token values use upstream-reported usage when it is available. For external providers, the input value may differ from billing usage because tokenizers, system prompts, and cache accounting vary by provider.

Destination labels use values from resolved routing configuration:

  • backend_type is model_server, external_provider, inference_pool, unresolved, or none.
  • model_route and backend_name use namespace/name; none means the label does not apply.
  • upstream_model is the model sent to the backend. Without a backend override, it is the requested model. LoRA requests use the matched adapter name.
  • model_server remains on kthena_router_active_upstream_requests for compatibility and is none for other backend types.

Scheduler and user-fairness queue

MetricTypeLabelsDescription
kthena_router_scheduler_plugin_duration_secondsHistogrammodel, plugin, typeScheduler plugin execution time; type is filter or score
kthena_router_fairness_queue_sizeGaugemodel, user_id="_all"Pending requests in the fairness queue, aggregated across users
kthena_router_fairness_queue_duration_secondsHistogrammodel, user_id="_all"Time spent waiting in the fairness queue, aggregated across users
kthena_router_fairness_queue_cancelled_totalCountermodel, user_id="_all"Requests cancelled or timed out while queued, aggregated across users
kthena_router_fairness_queue_dequeue_totalCountermodel, user_id="_all"Requests successfully dequeued, aggregated across users
kthena_router_fairness_queue_inflightGaugemodelRequests admitted through the fairness semaphore
kthena_router_fairness_queue_priority_refresh_totalCountermodelDequeue-time priority refresh and reinsert operations
kthena_router_fairness_queue_heap_rebuild_totalCountermodelFull heap rebuilds caused by priority drift

Raw user identifiers are deliberately not exported. The user_id label is fixed to _all, keeping cardinality bounded and avoiding exposure of user identity through the metrics endpoint.

Label compatibility and cardinality

Current router versions include model_route, backend_type, backend_name, and upstream_model on request, request-duration, and token metrics. They also include destination labels on kthena_router_active_upstream_requests. When upgrading from a version without those labels, Prometheus starts new time series; old counter series do not continue under the new label set.

Update dashboards, alerts, recording rules, and tests that match an exact label set. To reproduce the previous aggregate view, sum over the destination labels:

sum by (model, path, status_code, error_type) (
rate(kthena_router_requests_total[5m])
)

Destination labels come from resolved routing configuration and fixed enums; metrics never include request IDs, Secret names, error text, or raw user IDs. The path label does come from the request URL path, however, so routes with unbounded dynamic path segments can create unbounded series. Cardinality also grows with the product of models, routes, backends, upstream models, status codes, and error types. Normalize dynamic paths before they reach the router or drop/relabel them at scrape time, and estimate the remaining combinations for clusters with many routes or long retention.

Tokenizer and cache-aware scheduling

MetricTypeLabelsDescription
kthena_router_tokenizer_unsupported_engine_totalCountermodel, engineTokenizer lookups for which no pod used a supported engine
kthena_router_prefix_cache_match_ratioHistogrammodelBest prefix-cache match ratio for each scheduling attempt
kthena_router_prefix_cache_evictions_totalCountermodelPer-pod prefix-cache entries evicted at capacity
kthena_router_prefix_cache_entriesGaugenoneCurrent (prefix block, pod) entries across all local caches
kthena_router_kvcache_aware_match_ratioHistogrammodelBest external KV-cache match ratio for each attempt
kthena_router_kvcache_aware_redis_duration_secondsHistogrammodelBatched Redis lookup latency
kthena_router_kvcache_aware_tokenize_duration_secondsHistogrammodelPrompt tokenization latency for KV-cache-aware matching
kthena_router_kvcache_aware_errors_totalCountermodel, stageAborted KV-cache-aware attempts; stage is tokenize or redis

Session-boost queue

MetricTypeLabelsDescription
kthena_router_session_boost_queue_sizeGaugemodelPending requests in the session-boost queue
kthena_router_session_boost_queue_duration_secondsHistogrammodelTime spent waiting in the session-boost queue
kthena_router_session_boost_queue_cancelled_totalCountermodelRequests cancelled or timed out while queued
kthena_router_session_boost_queue_dequeue_totalCountermodelRequests successfully dequeued
kthena_router_session_boost_queue_inflightGaugemodelRequests admitted through the session-boost queue

Histogram buckets

MetricsBuckets
Request, prefill, and decode durations0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60 seconds
Scheduler plugin duration0.001, 0.005, 0.01, 0.05, 0.1, 0.5 seconds
Fairness and session-boost queue durations0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5 seconds
Prefix-cache and KV-cache match ratios0, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 1.0
KV-cache Redis and tokenization durations0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5 seconds

Access logs

When access logging is enabled, the middleware emits one record after every /v1/* request, including authentication rejections and requests that fail during parsing. It does not currently log non-/v1/* paths, including custom Gateway API paths. Health, metrics, and debug requests are also excluded.

Helm and the router binary enable access logging in text format by default. JSON is recommended for structured ingestion, but operators must opt in explicitly:

networking:
kthenaRouter:
accessLog:
enabled: true
format: json
output: stdout

When installing the standalone networking subchart, omit the networking: wrapper and start with kthenaRouter:.

For a standalone deployment, the equivalent environment variables are:

env:
- name: ACCESS_LOG_ENABLED
value: "true"
- name: ACCESS_LOG_FORMAT
value: "json"
- name: ACCESS_LOG_OUTPUT
value: "stdout"

The valid formats are text and json. Output can be stdout, stderr, or a file path. Prefer stdout in Kubernetes so the container runtime and log agent can collect the records. A file path is local to the container filesystem and needs a mounted volume plus a separate collection strategy.

The JSON field names below match the emitted contract:

{
"timestamp": "2026-01-09T14:35:22.147Z",
"method": "POST",
"path": "/v1/chat/completions",
"protocol": "HTTP/1.1",
"status_code": 200,
"model_name": "llama3-70b-instruct",
"model_route": "prod/llama3-70b-route",
"model_server": "prod/llama3-70b-server",
"selected_pod": "llama3-70b-deployment-7b9f4c2d-kjx9p",
"backend_type": "model_server",
"backend_name": "prod/llama3-70b-server",
"upstream_model": "llama3-70b-instruct",
"upstream_status_code": 200,
"upstream_attempts": 1,
"request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"input_tokens": 412,
"output_tokens": 189,
"duration_total": 3840,
"duration_request_processing": 65,
"duration_upstream_processing": 3480,
"duration_response_processing": 115
}

All duration fields are integer milliseconds. Optional zero-valued fields are omitted from JSON. There is no duration_queue field. duration_total is authoritative and can exceed the sum of the marked processing phases, including when routing or queue time falls between phase checkpoints. See the access-log field reference for field-by-field descriptions and error examples.

When ACCESS_LOG_OUTPUT is stdout or stderr, kubectl logs can contain access-log records alongside other process logs. Parse each input line as JSON and discard non-JSON process logs. This is safer than matching lines with a regular expression:

kubectl logs -n kthena-system deployment/kthena-router -f \
| jq --unbuffered -R 'fromjson? | select(type == "object")'

If access logs use a file path, they do not appear in kubectl logs; inspect the mounted file or the configured log collector instead. Access records can contain request IDs, routing object names, pod names, and error messages, so apply the same access controls and retention policy used for other potentially sensitive operational logs.

Health endpoints

EndpointSuccessful responsePurpose
/healthzHTTP 200, {"message":"ok"}Process liveness
/readyzHTTP 200, {"message":"router is ready"}Controller and datastore readiness

Until the controllers and datastore are ready, /readyz returns HTTP 503 with {"message":"router is not ready"}. The Helm liveness probe uses /healthz and the readiness probe uses /readyz.

With the inference Service port-forward running, verify both endpoints:

curl --fail --show-error --silent http://localhost:8080/healthz
curl --fail --show-error --silent http://localhost:8080/readyz

These commands assume the default tls.enabled: false. When inference TLS is enabled, use HTTPS and the CA and hostname appropriate for the router certificate. The dedicated metrics and debug listeners remain plain HTTP.

Debug and pprof endpoints

The debug server binds to localhost:15000 by default. It is intentionally not published by the Helm Service. A port-forward grants access to sensitive routing state and runtime profiles; keep it open only while diagnosing a trusted cluster. The handlers do not provide authentication of their own.

Configuration dump

EndpointDescription
/debug/config_dump/modelroutesAll ModelRoute resources known to the router
/debug/config_dump/modelserversAll ModelServer resources known to the router
/debug/config_dump/podsAll inference pods known to the router
/debug/config_dump/gatewaysAll Gateway resources known to the router
/debug/config_dump/httproutesAll HTTPRoute resources known to the router
/debug/config_dump/inferencepoolsAll InferencePool resources known to the router
/debug/config_dump/namespaces/{namespace}/modelroutes/{name}One namespaced ModelRoute
/debug/config_dump/namespaces/{namespace}/modelservers/{name}One namespaced ModelServer
/debug/config_dump/namespaces/{namespace}/pods/{name}One namespaced pod
/debug/config_dump/namespaces/{namespace}/gateways/{name}One namespaced Gateway
/debug/config_dump/namespaces/{namespace}/httproutes/{name}One namespaced HTTPRoute
/debug/config_dump/namespaces/{namespace}/inferencepools/{name}One namespaced InferencePool

Runtime profiling

EndpointDescription
/debug/pprof/pprof index
/debug/pprof/profileCPU profile
/debug/pprof/goroutineGoroutine profile
/debug/pprof/heapHeap profile
/debug/pprof/allocsAllocation profile
/debug/pprof/blockBlocking profile
/debug/pprof/mutexMutex-contention profile

With the debug port-forward running, inspect a text goroutine dump or capture a 30-second CPU profile:

curl --fail --show-error --silent \
'http://localhost:15000/debug/pprof/goroutine?debug=1'

curl --fail --show-error --silent \
'http://localhost:15000/debug/pprof/profile?seconds=30' \
--output /tmp/kthena-router-cpu.pprof
go tool pprof /tmp/kthena-router-cpu.pprof

Troubleshooting examples

Start only the port-forwards needed for the checks below.

# Confirm that the metrics Service has a ready pod endpoint.
kubectl get -n kthena-system endpointslice \
-l kubernetes.io/service-name=kthena-router-metrics

# Request counters by model and result. No output means no qualifying request
# has been recorded since this router process started.
curl --fail --show-error --silent http://localhost:9090/metrics \
| grep '^kthena_router_requests_total'

# Router configuration and currently known pods.
curl --fail --show-error --silent \
http://localhost:15000/debug/config_dump/modelservers | jq .
curl --fail --show-error --silent \
http://localhost:15000/debug/config_dump/pods | jq .

# Recent 5xx access records (requires JSON access-log format).
kubectl logs -n kthena-system deployment/kthena-router --since=30m \
| jq -R '
fromjson?
| select(.status_code? >= 500)
| {
timestamp,
model: .model_name,
error,
error_origin,
backend: .backend_name,
pod: .selected_pod,
duration: .duration_total
}
'

# Requests slower than four seconds (requires JSON access-log format).
kubectl logs -n kthena-system deployment/kthena-router --since=20m \
| jq -R '
fromjson?
| select(.duration_total? > 4000)
| {
model: .model_name,
total: .duration_total,
upstream: .duration_upstream_processing,
backend: .backend_name,
pod: .selected_pod
}
'

Use the signals in this order when diagnosing a request failure:

  1. Check /readyz to distinguish startup or synchronization failures.
  2. Check the configuration dump to confirm that the expected route, backend, and pods are present in this router process.
  3. Check the request and queue metrics for aggregate impact.
  4. Correlate a JSON access log by request_id for the selected backend, error origin, and latency phases.
  5. Capture a runtime profile only when the preceding signals indicate a router-process performance problem.