---
title: Monitoring
---
# Monitoring
Set up Prometheus monitoring, OpenTelemetry tracing, and Grafana dashboards for SMG.
#### Before you begin
- Completed the [Getting Started](index.md) guide
- Prometheus server (or follow steps below to deploy)
- Grafana (optional, for dashboards)
- OTLP collector (optional, for distributed tracing)
---
## Enable Metrics
SMG always serves Prometheus metrics on a dedicated port, `29000` by default. The flags below set the listener explicitly.
### Start SMG with metrics
```bash
smg launch \
--worker-urls http://worker:8000 \
--prometheus-port 29000 \
--prometheus-host 0.0.0.0
```
At startup SMG logs `Metrics server listening on (/metrics)`. The port must be greater than `0`: `--prometheus-port 0` is rejected at startup even though `--help` describes it as an OS-assigned port. If the port is already in use, SMG fails at startup with `failed to bind metrics server on `.
!!! warning "The metrics listener has no authentication"
`/metrics` answers anyone who can reach the port, in every configuration (`--api-key` and the control-plane keys do not apply to it), and its per-worker series name each backend by its address. Bind it to a private address with `--prometheus-host`, and in Kubernetes fence the port with a NetworkPolicy that admits only the scraper. See [Default posture](control-plane-auth.md#default-posture).
### Verify metrics endpoint
```bash
curl http://localhost:29000/metrics
```
You should see Prometheus-formatted metrics:
```
# HELP smg_http_requests_total Total HTTP requests by method and path
# TYPE smg_http_requests_total counter
smg_http_requests_total{method="POST",path="/v1/chat/completions"} 1234
...
```
The engines' own metrics are available separately at `/engine_metrics` on the main port; see [Engine Metrics Passthrough](../reference/metrics.md#engine-metrics-passthrough).
---
## OpenTelemetry Tracing
SMG supports distributed tracing via OpenTelemetry.
### Enable tracing
```bash
smg launch \
--worker-urls http://worker:8000 \
--enable-trace \
--otlp-traces-endpoint localhost:4317
```
### Configuration
| Flag | Default | Description |
|------|---------|-------------|
| `--enable-trace` | `false` | Enable OpenTelemetry tracing |
| `--otlp-traces-endpoint` | `localhost:4317` | OTLP gRPC collector endpoint, as `host:port` (`http://` is added when no scheme is given) |
Spans are batched and exported over OTLP gRPC with the service name `smg`. The gateway exports its own request spans: `http_request` for each HTTP request that matches a route (method, URI, request ID, status code, and latency in microseconds) and `grpc_execute` for each dispatch through the gRPC pipeline (request type, request ID, model, and mode).
### Trace propagation
With `--enable-trace`, SMG uses W3C TraceContext headers:
- `traceparent` and `tracestate` on an incoming client request make the gateway's spans part of the caller's trace.
- Requests the HTTP routers (regular and PD) send to workers carry the current `traceparent` and `tracestate`. Requests to gRPC workers do not carry trace context in v1.11.0.
Without `--enable-trace`, SMG neither reads nor adds these headers.
---
## Prometheus Configuration
### Basic configuration
```yaml title="prometheus.yml"
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'smg'
static_configs:
- targets: ['localhost:29000']
metrics_path: /metrics
```
To also collect the engines' metrics through the gateway, add a job for `/engine_metrics` on the main port. Every sample carries a `worker_addr` label:
```yaml title="prometheus.yml"
- job_name: 'smg-engines'
static_configs:
- targets: ['localhost:30000']
metrics_path: /engine_metrics
```
### Kubernetes ServiceMonitor
For Prometheus Operator:
```yaml title="smg-servicemonitor.yaml"
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: smg
namespace: inference
labels:
app: smg
spec:
selector:
matchLabels:
app: smg
endpoints:
- port: metrics
interval: 15s
path: /metrics
namespaceSelector:
matchNames:
- inference
```
If you deploy with the SMG Helm chart, set `router.metrics.serviceMonitor.enabled=true` and the chart creates a ServiceMonitor for the router's `metrics` port.
---
## Key Metrics by Layer
### Layer 0: Runtime Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `smg_tokio_event_loop_stalls_total` | Counter | Event-loop stalls (async runtime blocked for more than 5 ms) |
| `smg_tokio_worker_busy_ratio` | Gauge | Busy fraction of each runtime worker thread |
| `smg_allocator_allocated_bytes` | Gauge | Bytes in live allocations (jemalloc) |
| `smg_allocator_resident_bytes` | Gauge | Resident bytes of the jemalloc heap |
### Layer 1: HTTP Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `smg_http_requests_total` | Counter | Requests by method, path |
| `smg_http_request_duration_seconds` | Histogram | Request latency |
| `smg_http_responses_total` | Counter | Responses by path, status_code, error_code |
| `smg_http_connections_active` | Gauge | Requests in flight |
| `smg_http_rate_limit_total` | Counter | Concurrency-limit decisions |
| `smg_admission_queue_depth` | Gauge | Requests waiting for an admission token |
| `smg_admission_queue_rejected_total` | Counter | Admission rejections by reason |
### Layer 2: Router Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `smg_router_requests_total` | Counter | Requests by router_type, model, endpoint |
| `smg_router_ttft_seconds` | Histogram | Time to first token (gRPC streaming) |
| `smg_router_tpot_seconds` | Histogram | Time per output token (gRPC streaming) |
| `smg_router_tokens_total` | Counter | Tokens by type (input/output) |
| `smg_pd_ttft_seconds` | Histogram | End-to-end time to first token of PD requests |
| `smg_pd_admission_sheds_total` | Counter | PD dispatches shed because the decode engine had no room |
| `smg_tokenizer_cache_lookups_total` | Counter | Tokenizer cache lookups by layer, result |
### Layer 3: Worker Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `smg_worker_health` | Gauge | Health status (1=Ready, 0=not Ready, -1=removed) |
| `smg_worker_requests_active` | Gauge | Active requests per worker |
| `smg_worker_cb_state` | Gauge | Circuit breaker state |
| `smg_worker_retries_total` | Counter | Retry attempts |
| `smg_workers_overloaded` | Gauge | Workers excluded as overloaded, per model |
| `smg_worker_overload_shed_total` | Counter | Requests shed with `worker_overload_protection_shed`, by stage |
| `smg_engine_token_usage` | Gauge | Engine-reported KV-cache usage per worker |
### Routing Policy Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `smg_cache_tree_tokens` | Gauge | Cache-aware token-tree size per model (gRPC) |
| `smg_cache_tree_chars` | Gauge | Cache-aware string-tree size per model (HTTP) |
| `smg_cache_aware_policy_branch_total` | Counter | Cache-aware routing decisions by outcome |
### Layer 5: MCP Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `smg_mcp_tool_calls_total` | Counter | Tool invocations by tool_name, result |
| `smg_mcp_tool_duration_seconds` | Histogram | Tool execution time |
| `smg_mcp_servers_active` | Gauge | Connected MCP servers |
Several of these exist only when their feature is on: admission metrics need `--max-concurrent-requests` (the multimodal rejection reasons need `--multimodal-max-inflight-bytes` instead), the `smg_workers_overloaded` gauge needs worker overload protection, and PD metrics need PD mode.
Unreleased: current main publishes `smg_worker_overload_shed_total` at zero for every stage from start-up, whether or not overload protection is enabled, so the series exist before any shed; only the gauge still waits for protection to flag a worker. See [Overload Protection Metrics](../reference/metrics.md#overload-protection-metrics).
[View all metrics →](../reference/metrics.md)
---
## Grafana Dashboards
### Essential panels
**Request Rate**
```promql
sum(rate(smg_http_requests_total[5m]))
```
**P99 Latency**
```promql
histogram_quantile(0.99, sum by (le) (rate(smg_http_request_duration_seconds_bucket[5m])))
```
**Error Rate**
```promql
sum(rate(smg_http_responses_total{status_code=~"5.."}[5m]))
/ sum(rate(smg_http_responses_total[5m]))
```
**`/v1/responses` Success Rate**
```promql
sum(rate(smg_http_responses_total{path="/v1/responses",status_code=~"2.."}[5m]))
/ sum(rate(smg_http_responses_total{path="/v1/responses"}[5m]))
```
**Time to First Token (TTFT)**
```promql
histogram_quantile(0.5, sum by (le) (rate(smg_router_ttft_seconds_bucket[5m])))
```
**Tokens per Second**
```promql
sum(rate(smg_router_tokens_total[5m]))
```
**Healthy Workers**
```promql
count(smg_worker_health == 1)
```
### Capacity and overload panels
**Admission Queue** (depth and rejections by reason)
```promql
smg_admission_queue_depth
sum by (reason) (rate(smg_admission_queue_rejected_total[5m]))
```
**Overload Shedding** (sheds by stage and vetoed workers per model)
```promql
sum by (stage) (rate(smg_worker_overload_shed_total[5m]))
smg_workers_overloaded
```
**PD Admission** (dispatches that waited for, or were shed by, the decode engine's running window)
```promql
rate(smg_pd_admission_waits_total[5m])
rate(smg_pd_admission_sheds_total[5m])
```
**Allocator Memory** (live allocations vs. resident heap)
```promql
smg_allocator_allocated_bytes
smg_allocator_resident_bytes
```
**Cache Tree Size** (per model)
```promql
smg_cache_tree_tokens
smg_cache_tree_chars
```
**Tokenizer Cache Hit Rate** (per layer)
```promql
sum by (layer) (rate(smg_tokenizer_cache_lookups_total{result="hit"}[5m]))
/ sum by (layer) (rate(smg_tokenizer_cache_lookups_total[5m]))
```
**Event-Loop Stalls**
```promql
rate(smg_tokio_event_loop_stalls_total[5m])
```
---
## Alerting Rules
```yaml title="smg-alerts.yaml"
groups:
- name: smg
rules:
- alert: SMGHighErrorRate
expr: |
sum(rate(smg_http_responses_total{status_code=~"5.."}[5m]))
/ sum(rate(smg_http_responses_total[5m])) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate on SMG"
description: "Error rate is {{ $value | humanizePercentage }}"
- alert: SMGWorkerUnhealthy
expr: smg_worker_health == 0
for: 1m
labels:
severity: warning
annotations:
summary: "SMG worker unhealthy"
description: "Worker {{ $labels.worker }} is unhealthy"
- alert: SMGHighLatency
expr: |
histogram_quantile(0.99, sum by (le) (rate(smg_http_request_duration_seconds_bucket[5m]))) > 5
for: 5m
labels:
severity: warning
annotations:
summary: "High latency on SMG"
description: "P99 latency is {{ $value }}s"
- alert: SMGCircuitBreakerOpen
expr: smg_worker_cb_state == 1
for: 1m
labels:
severity: critical
annotations:
summary: "Circuit breaker open"
description: "Circuit breaker for {{ $labels.worker }} is open"
- alert: SMGHighTTFT
expr: |
histogram_quantile(0.95, sum by (le) (rate(smg_router_ttft_seconds_bucket[5m]))) > 2
for: 5m
labels:
severity: warning
annotations:
summary: "High time to first token"
description: "P95 TTFT is {{ $value }}s"
- alert: SMGRateLimitRejections
expr: sum(rate(smg_http_rate_limit_total{result="rejected"}[5m])) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "High rate limit rejections"
description: "{{ $value }} rejections/sec"
- alert: SMGAdmissionQueueBacklog
expr: min_over_time(smg_admission_queue_depth[10m]) > 0
labels:
severity: warning
annotations:
summary: "Admission queue has not drained for 10 minutes"
description: "At least {{ $value }} requests waiting for an admission token"
- alert: SMGOverloadShedding
expr: sum by (stage) (rate(smg_worker_overload_shed_total[5m])) > 0
for: 5m
labels:
severity: warning
annotations:
summary: "SMG is shedding requests at stage {{ $labels.stage }}"
description: "{{ $value }} requests/sec answered with 503 worker_overload_protection_shed"
- alert: SMGEventLoopStalls
expr: rate(smg_tokio_event_loop_stalls_total[5m]) > 1
for: 10m
labels:
severity: warning
annotations:
summary: "SMG async runtime is stalling"
description: "{{ $value }} event-loop stalls/sec"
```
`SMGOverloadShedding` also covers PD admission, whose sheds are counted with `stage="pd_admission"`. Tune the thresholds to your traffic.
---
## Useful Queries
### Request analysis
```promql
# Request rate by endpoint
sum by (path) (rate(smg_http_requests_total[5m]))
# Success rate
sum(rate(smg_http_responses_total{status_code="200"}[5m]))
/ sum(rate(smg_http_responses_total[5m]))
# Latency percentiles
histogram_quantile(0.50, sum by (le) (rate(smg_http_request_duration_seconds_bucket[5m])))
histogram_quantile(0.95, sum by (le) (rate(smg_http_request_duration_seconds_bucket[5m])))
histogram_quantile(0.99, sum by (le) (rate(smg_http_request_duration_seconds_bucket[5m])))
# Gateway-generated errors (sheds, rejections) by code
sum by (error_code) (rate(smg_http_responses_total{error_code!=""}[5m]))
```
### LLM performance
```promql
# Tokens per second by model
sum by (model) (rate(smg_router_tokens_total[5m]))
# TTFT by model
histogram_quantile(0.5, sum by (model, le) (rate(smg_router_ttft_seconds_bucket[5m])))
# Input/output token ratio
sum(rate(smg_router_tokens_total{token_type="output"}[5m]))
/ sum(rate(smg_router_tokens_total{token_type="input"}[5m]))
```
### Worker analysis
```promql
# Load distribution
smg_worker_requests_active / ignoring(worker) group_left sum without (worker) (smg_worker_requests_active)
# Unhealthy workers
count(smg_worker_health == 0)
# Circuit breaker states
count by (worker) (smg_worker_cb_state == 1)
# Engine KV-cache usage per worker (removed and non-Ready workers report -1)
max by (worker) (smg_engine_token_usage >= 0)
```
### MCP tool analysis
```promql
# Tool success rate
sum(rate(smg_mcp_tool_calls_total{result="success"}[5m]))
/ sum(rate(smg_mcp_tool_calls_total[5m]))
# Most used tools
topk(10, sum by (tool_name) (rate(smg_mcp_tool_calls_total[5m])))
# Slowest tools
topk(5, histogram_quantile(0.95, sum by (tool_name, le) (rate(smg_mcp_tool_duration_seconds_bucket[5m]))))
```
---
## Verification
```bash
# Check metrics are being scraped
curl -s http://prometheus:9090/api/v1/targets | jq '.data.activeTargets[] | select(.labels.job=="smg")'
# Query a metric
curl -s 'http://prometheus:9090/api/v1/query?query=smg_http_requests_total' | jq
# Check alerts
curl -s http://prometheus:9090/api/v1/alerts | jq
# Engine load the gateway is routing on (cached snapshot, fleet totals)
curl -s http://localhost:30000/loads | jq '.aggregate'
```
---
## Troubleshooting
??? question "Metrics endpoint not responding"
1. Check the startup log for `Metrics server listening on (/metrics)`, which shows the address actually bound. A `failed to bind metrics server` error usually means the port is already in use.
2. Check the port is listening:
```bash
netstat -tlnp | grep 29000
```
3. Check firewall rules allow access
??? question "Traces not appearing"
1. Verify the OTLP endpoint is reachable from the gateway:
```bash
nc -zv localhost 4317
```
2. Check SMG was started with `--enable-trace`
3. Verify the collector is receiving spans. Requests to gRPC workers do not carry trace context, so engine-side spans join the trace only for HTTP workers.
4. Keep SMG's log level at `info` or more verbose. The log filter also applies to spans, and the request spans are INFO-level, so `--log-level warn` (or a `RUST_LOG` that drops INFO for `smg`) filters them out before they are exported.
??? question "Missing metrics"
1. A series appears only after the first event that records it, so a metric can be absent on an idle gateway.
2. Some groups need their feature enabled: admission metrics need `--max-concurrent-requests`, the `smg_workers_overloaded` gauge needs worker overload protection, and PD, discovery, RL, and mesh metrics need their modes. Allocator metrics are absent on musl and MSVC builds.
3. Some metrics only appear for specific paths (for example, TTFT is recorded for streaming responses on the gRPC router).
4. Verify metric names against the [Metrics Reference](../reference/metrics.md). Names such as `smg_router_stage_duration_seconds` and the `smg_db_*` metrics are declared in code but never emitted.
Unreleased: current main publishes the overload-shed and retry families at zero from start-up — whether or not overload protection is enabled — and each worker's circuit-breaker transition counters at zero when that worker is registered. The eager retry series cover the `regular`, `prefill`, and `decode` worker types only; the external-provider counters (`worker_type="external"`) still appear on their first respective event, `smg_worker_retries_total` on the first retry and `smg_worker_retries_exhausted_total` on the first exhaustion. On a gateway built from main, absence of the start-up families points at a scrape problem rather than inactivity, while a missing transition series means no worker with that label is registered. The [Metrics Reference](../reference/metrics.md) lists the exact series.
---
## What's Next?
- [Configure Logging](logging.md) — Structured log aggregation
- [Configure TLS](tls.md) — Secure client-to-gateway traffic
- [Metrics Reference](../reference/metrics.md) — Complete metrics documentation