Skip to content

Commit 998f31e

Browse files
authored
Merge pull request #1037 from Dstack-TEE/feat/gateway-metrics
feat(gateway): add a /metrics endpoint on the admin listener
2 parents 5bc82ec + ae2ac96 commit 998f31e

7 files changed

Lines changed: 933 additions & 15 deletions

File tree

docs/dstack-gateway.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,49 @@ insecure_no_auth = false
8888
The admin server is fail-closed: if it is enabled with no `admin_token` and no `htpasswd_file`, and `insecure_no_auth` is `false`, it refuses to start rather than exposing an unauthenticated admin API.
8989

9090
Clients authenticate by sending `Authorization: Bearer <token>` or the `X-Admin-Token: <token>` header.
91+
92+
## Metrics
93+
94+
The admin server exposes Prometheus metrics at `GET /metrics`. It is part of the
95+
admin API, so it is only reachable when `core.admin.enabled` is true and it
96+
requires the same credentials — unless `insecure_no_auth` is set, which exposes
97+
it along with the rest of the admin API. The series name domains, node ids and
98+
instance counts, which is topology that should not be readable without
99+
authentication.
100+
101+
```yaml
102+
scrape_configs:
103+
- job_name: dstack-gateway
104+
static_configs:
105+
- targets: ["<core.admin.address>"]
106+
authorization:
107+
credentials: "<the admin token>"
108+
```
109+
110+
### Cluster-scoped vs node-local series
111+
112+
`dstack_gateway_cluster_*` describes replicated state: every node in the cluster
113+
reports the same value, so summing across targets multiplies it by the number of
114+
nodes. Everything else describes what one process did and sums normally.
115+
116+
```promql
117+
# Instances in the routing table — replicated, so take one node's view
118+
max(dstack_gateway_cluster_instances)
119+
120+
# Connections across the fleet — node-local, so add them up
121+
sum(dstack_gateway_connections)
122+
123+
# Nodes disagreeing about who is up: this is the replication-lag signal
124+
max(dstack_gateway_cluster_nodes_active) - min(dstack_gateway_cluster_nodes_active)
125+
```
126+
127+
### Series worth alerting on
128+
129+
| Metric | Why |
130+
|---|---|
131+
| `dstack_gateway_wg_reconfigure_failures_total` | The gateway could not push a WireGuard config: it failed to render, failed to write, or `wg syncconf` rejected the whole file over one bad peer stanza. Routing updates have stopped reaching the data plane while the gateway still looks healthy. |
132+
| `dstack_gateway_kv_decode_failures_total` | A replicated record that fails to decode is skipped, which makes the CVM behind it silently unroutable. Labelled by key prefix. Alert on `> 0`; the magnitude counts how often a bad record was *read*, not how many are bad, so do not read it as a severity. |
133+
| `dstack_gateway_kv_peer_buffered_logs` | Entries still buffered for a peer. Sustained growth means that peer stopped acknowledging and the two nodes are drifting apart. |
134+
| `dstack_gateway_cluster_cert_not_after_seconds` | Certificate expiry per domain; alert on `- time()` falling under the renewal window. Capped at 256 series — compare `dstack_gateway_cluster_cert_domains` to see whether the cap was hit. |
135+
| `dstack_gateway_kv_persist_failures_total` | Periodic snapshots are failing, so a restart replays a growing WAL. |
136+
| `dstack_gateway_kv_persist_failures_total` | Periodic snapshots are failing, so a restart replays a growing WAL. |

dstack/gateway/src/kv/mod.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@ pub mod keys {
265265
pub const CERT_PREFIX: &str = "cert/";
266266
pub const DNS_CRED_PREFIX: &str = "dns_cred/";
267267
pub const DNS_CRED_DEFAULT: &str = "dns_cred_default";
268+
/// Shared by the `GLOBAL_*` keys below; not itself a key.
269+
pub const GLOBAL_PREFIX: &str = "global/";
268270
pub const GLOBAL_CERTBOT_CONFIG: &str = "global/certbot_config";
269271
pub const GLOBAL_ACME_CREDENTIALS: &str = "global/acme_credentials";
270272
pub const GLOBAL_ACME_ATTESTATION: &str = "global/acme_attestation";
@@ -440,6 +442,7 @@ impl GetPutCodec for NodeState {
440442
.and_then(|entry| match decode(entry.value.as_ref()?) {
441443
Ok(value) => Some(value),
442444
Err(e) => {
445+
crate::metrics::record_decode_failure(key);
443446
warn!("failed to decode value for key {key}: {e:?}");
444447
None
445448
}
@@ -460,6 +463,7 @@ impl GetPutCodec for NodeState {
460463
let value = match decode(entry.value.as_ref()?) {
461464
Ok(value) => value,
462465
Err(e) => {
466+
crate::metrics::record_decode_failure(key);
463467
warn!("failed to decode value for key {key}: {e:?}");
464468
return None;
465469
}
@@ -476,6 +480,7 @@ impl GetPutCodec for NodeState {
476480
let value = match decode(entry.value.as_ref()?) {
477481
Ok(value) => value,
478482
Err(e) => {
483+
crate::metrics::record_decode_failure(key);
479484
warn!("failed to decode value for key {key}: {e:?}");
480485
return None;
481486
}
@@ -626,6 +631,32 @@ impl KvStore {
626631
.collect()
627632
}
628633

634+
/// Whether a node counts as active. A node with no recorded status is up.
635+
///
636+
/// The routing path and the metrics sampler both filter on this, and they
637+
/// have to agree: a gauge that counts a node the router has dropped is
638+
/// describing a routing table that does not exist.
639+
pub(crate) fn node_is_active(status: Option<&NodeStatus>) -> bool {
640+
!matches!(status, Some(NodeStatus::Down))
641+
}
642+
643+
/// Count all and active nodes, without materialising `GatewayNodeInfo`.
644+
///
645+
/// A scrape wants two numbers. Reaching them through `get_all_nodes()` and
646+
/// `get_active_nodes()` instead means loading the node table twice, cloning
647+
/// five strings per node, and taking the ephemeral lock once per node for a
648+
/// `last_seen` that the count never reads -- all of it under the proxy lock
649+
/// that the data path takes on every connection.
650+
pub fn count_nodes(&self) -> (u64, u64) {
651+
let statuses = self.load_all_node_statuses();
652+
let nodes = self.load_all_nodes();
653+
let active = nodes
654+
.keys()
655+
.filter(|id| Self::node_is_active(statuses.get(id)))
656+
.count() as u64;
657+
(nodes.len() as u64, active)
658+
}
659+
629660
// ==================== Connection Count Sync ====================
630661

631662
/// Sync connection count for an instance (from this node)
@@ -879,6 +910,7 @@ impl KvStore {
879910
match decode(value) {
880911
Ok(config) => Some(config),
881912
Err(e) => {
913+
crate::metrics::record_decode_failure(key);
882914
warn!("failed to decode cert config for key {key}: {e:?}");
883915
None
884916
}
@@ -951,6 +983,7 @@ impl KvStore {
951983
match decode(value) {
952984
Ok(data) => Some((domain.to_string(), data)),
953985
Err(e) => {
986+
crate::metrics::record_decode_failure(key);
954987
warn!("failed to decode cert data for key {key}: {e:?}");
955988
None
956989
}
@@ -1146,6 +1179,7 @@ impl KvStore {
11461179
match decode(value) {
11471180
Ok(att) => Some(att),
11481181
Err(e) => {
1182+
crate::metrics::record_decode_failure(key);
11491183
warn!("failed to decode attestation for key {key}: {e:?}");
11501184
None
11511185
}

dstack/gateway/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ mod debug_service;
2929
mod distributed_certbot;
3030
mod kv;
3131
mod main_service;
32+
mod metrics;
3233
mod models;
3334
mod pp;
3435
mod proxy;

dstack/gateway/src/main_service.rs

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,10 @@ fn start_wavekv_watch_task(proxy: Proxy) -> Result<()> {
845845
match kv_store_for_persist.persist_if_dirty() {
846846
Ok(true) => info!("WaveKV: periodic persist completed"),
847847
Ok(false) => {} // No changes to persist
848-
Err(err) => error!("WaveKV: periodic persist failed: {err:?}"),
848+
Err(err) => {
849+
crate::metrics::record_kv_persist_failure();
850+
error!("WaveKV: periodic persist failed: {err:?}");
851+
}
849852
}
850853
}
851854
});
@@ -1183,6 +1186,20 @@ impl ProxyState {
11831186
}
11841187

11851188
pub(crate) fn reconfigure(&mut self) -> Result<()> {
1189+
// Every way out of here that is not a clean apply leaves the data plane
1190+
// on the routing table it already had, so they all feed one counter --
1191+
// the early returns included. A config that cannot be rendered or
1192+
// written never reaches `wg` at all, and both call sites of this
1193+
// function only log the `Err`, so a full disk would otherwise look
1194+
// exactly like having nothing to apply.
1195+
let result = self.reconfigure_inner();
1196+
if result.is_err() {
1197+
crate::metrics::record_wg_reconfigure(false);
1198+
}
1199+
result
1200+
}
1201+
1202+
fn reconfigure_inner(&mut self) -> Result<()> {
11861203
let wg_config = self.generate_wg_config()?;
11871204
// the rendered config carries the interface's WireGuard private key.
11881205
safe_write_with_mode(&self.config.wg.config_path, wg_config, 0o600)
@@ -1192,8 +1209,18 @@ impl ProxyState {
11921209
let config_path = &self.config.wg.config_path;
11931210

11941211
match cmd!(wg syncconf $ifname $config_path) {
1195-
Ok(_) => info!("wg config updated"),
1196-
Err(err) => error!("failed to set wg config: {err:?}"),
1212+
Ok(_) => {
1213+
crate::metrics::record_wg_reconfigure(true);
1214+
info!("wg config updated");
1215+
}
1216+
Err(err) => {
1217+
// `wg syncconf` rejects the whole file when one peer stanza is
1218+
// bad, and this stays `Ok` for the caller as it always has, so
1219+
// the counter is the only signal that routing updates stopped
1220+
// reaching the data plane.
1221+
crate::metrics::record_wg_reconfigure(false);
1222+
error!("failed to set wg config: {err:?}");
1223+
}
11971224
}
11981225
Ok(())
11991226
}
@@ -1469,16 +1496,9 @@ impl ProxyState {
14691496
self.kv_store
14701497
.load_all_nodes()
14711498
.into_iter()
1472-
.filter(|(id, _)| {
1473-
if !exclude_down {
1474-
return true;
1475-
}
1476-
// Exclude nodes with status "down"
1477-
match node_statuses.get(id) {
1478-
Some(NodeStatus::Down) => false,
1479-
_ => true, // Include Up or nodes without explicit status
1480-
}
1481-
})
1499+
// Shared with the metrics sampler so the gauge and the routing
1500+
// table cannot disagree about what "active" means.
1501+
.filter(|(id, _)| !exclude_down || KvStore::node_is_active(node_statuses.get(id)))
14821502
.map(|(id, node)| GatewayNodeInfo {
14831503
id,
14841504
uuid: node.uuid,

0 commit comments

Comments
 (0)