Метрики /metrics для центрального Prometheus + генерация scrape-таргетов

--metrics-port (0.0.0.0, в отличие от --health-port — тот 127.0.0.1-only)
отдаёт netrunner_connections_active/total, netrunner_auth_validate_duration_seconds,
netrunner_auth_failures_total, netrunner_circuit_breaker_open. Тот же
hand-rolled HTTP-паттерн, что и health.rs — без HTTP-фреймворка ради
одного эндпоинта.

scripts/generate-prometheus-targets.mjs генерит список scrape-таргетов
из nodes.json — job update-observability-targets в deploy.yml гоняет
его при каждом деплое нод и заливает на VPS с данными
(netrunner-data/observability/targets/nodes.json), чтобы центральный
Prometheus не терял из виду ноды без ручной правки конфига.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:21:26 +07:00
parent fafd573a28
commit cfcb1afcec
11 changed files with 367 additions and 13 deletions
+2
View File
@@ -15,3 +15,5 @@ serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13.2", features = ["json"] }
async-trait = "0.1.89"
dashmap = "6.1.0"
metrics = "0.24.3"
metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
+1 -1
View File
@@ -5,7 +5,7 @@ After=network.target
[Service]
User=root
WorkingDirectory=/root/netr-core
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=8443 --health-port=9092
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=8443 --health-port=9092 --metrics-port=9094
Restart=always
RestartSec=3
LimitNOFILE=65535
+1 -1
View File
@@ -5,7 +5,7 @@ After=network.target
[Service]
User=root
WorkingDirectory=/root/netr-core
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 --health-port=9091
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 --health-port=9091 --metrics-port=9093
Restart=always
RestartSec=3
LimitNOFILE=65535
+7
View File
@@ -62,6 +62,7 @@ impl Circuit {
let mut state = self.state.lock().unwrap();
state.consecutive_failures = 0;
state.open_until = None;
metrics::gauge!("netrunner_circuit_breaker_open").set(0.0);
}
/// Считать неудачей только сетевые ошибки/таймауты/5xx — HTTP 401/403 на
@@ -71,6 +72,7 @@ impl Circuit {
state.consecutive_failures += 1;
if state.consecutive_failures >= FAILURE_THRESHOLD {
state.open_until = Some(Instant::now() + CIRCUIT_OPEN_COOLDOWN);
metrics::gauge!("netrunner_circuit_breaker_open").set(1.0);
warn!(
failures = state.consecutive_failures,
"Circuit breaker разомкнут: бэкенд не отвечает {} раз подряд",
@@ -140,6 +142,7 @@ struct UsageResponse {
impl AuthValidator for BackendClient {
async fn validate(&self, token: &str) -> Result<UserQuota, AppError> {
if token.is_empty() {
metrics::counter!("netrunner_auth_failures_total").increment(1);
return Err(AppError::new(
netrunner_logger::ERR_AUTH_FAILED,
"Доступ запрещен",
@@ -158,6 +161,7 @@ impl AuthValidator for BackendClient {
return Err(Self::circuit_open_error());
}
let request_start = Instant::now();
let resp = self
.http
.post(format!("{}/api/v1/internal/validate", self.base_url))
@@ -173,6 +177,8 @@ impl AuthValidator for BackendClient {
e.to_string(),
)
})?;
metrics::histogram!("netrunner_auth_validate_duration_seconds")
.record(request_start.elapsed().as_secs_f64());
// 5xx — признак нездоровья самого бэкенда, считается неудачей для
// circuit breaker'а. 4xx (например 401 на невалидный токен) — это
@@ -181,6 +187,7 @@ impl AuthValidator for BackendClient {
self.circuit.record_failure();
}
if !resp.status().is_success() {
metrics::counter!("netrunner_auth_failures_total").increment(1);
return Err(AppError::new(
netrunner_logger::ERR_AUTH_FAILED,
"Доступ запрещен",
+3 -6
View File
@@ -10,8 +10,8 @@
//! [`ServerDiagnosticsLogger::snapshot_all_sessions`]).
use netrunner_core::net::diagnostics::{
self, DiagnosticsEvent, DiagnosticsSnapshot, DiagnosticsStore, TunnelMetrics,
current_timestamp_ms,
self, current_timestamp_ms, DiagnosticsEvent, DiagnosticsSnapshot, DiagnosticsStore,
TunnelMetrics,
};
use netrunner_core::net::SessionManager;
use netrunner_logger::{error, info, warn};
@@ -192,10 +192,7 @@ impl ServerDiagnosticsLogger {
let mut rotated = self.log_path.clone();
rotated.set_extension("jsonl.1");
tokio::fs::rename(&self.log_path, &rotated).await?;
info!(
"Diagnostics log rotated → {}",
rotated.display()
);
info!("Diagnostics log rotated → {}", rotated.display());
}
_ => {}
}
+26 -1
View File
@@ -13,6 +13,7 @@
mod backend_client;
mod diagnostics;
mod health;
mod metrics_server;
mod network;
use clap::Parser;
use netrunner_core::net::AuthValidator;
@@ -88,6 +89,13 @@ struct Args {
/// этой же машине). Не задан — health-эндпоинт выключен.
#[arg(long)]
health_port: Option<u16>,
/// Порт для `/metrics` (Prometheus text exposition) — в отличие от
/// `--health-port`, биндится на 0.0.0.0 (нужен для скрейпа удалённым
/// центральным Prometheus), ОБЯЗАТЕЛЬНО зафайрволить на IP
/// observability-VPS. Не задан — метрики выключены.
#[arg(long)]
metrics_port: Option<u16>,
}
fn main() {
@@ -106,8 +114,15 @@ fn main() {
None
};
// Регистрируется один раз, до первого metrics::counter!/gauge!/histogram! —
// если --metrics-port не задан, вызовы макросов молча уходят в
// no-op recorder по умолчанию (штатное поведение крейта metrics).
let metrics_handle = args
.metrics_port
.map(|_| metrics_server::install_recorder());
let net = Network::new(
args.host,
args.host.clone(),
args.port,
args.decoy_host,
auth,
@@ -128,6 +143,16 @@ fn main() {
net.run(run_token).await;
});
if let (Some(port), Some(handle)) = (args.metrics_port, metrics_handle) {
let metrics_token = token.clone();
tokio::spawn(metrics_server::run(
"0.0.0.0".to_string(),
port,
handle,
metrics_token,
));
}
shutdown_signal(token).await;
if let Err(e) = run_handle.await {
error!(error = ?e, "Задача сервера завершилась с паникой при остановке");
+60
View File
@@ -0,0 +1,60 @@
//! `/metrics` для центрального Prometheus (см.
//! `netrunner-data/docker-compose.observability.yml`) — в отличие от
//! `/health` (127.0.0.1-only, см. `health.rs`), этот порт публикуется наружу
//! и ОБЯЗАТЕЛЬНО должен быть зафайрволен на IP observability-VPS: любой,
//! кто до него дотянется, увидит число активных соединений/трафик этой
//! ноды (не секрет пользователей, но разведка для DPI/блокировщика).
//!
//! Тот же hand-rolled HTTP-паттерн, что и в `health.rs` (не тянуть HTTP-
//! фреймворк ради одного эндпоинта на бинарнике, для которого важен размер
//! и минимальные зависимости) — просто рендерит текущий снапшот метрик из
//! уже установленного `PrometheusHandle` на каждый коннект.
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use netrunner_logger::{error, info, warn};
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
/// Регистрирует глобальный recorder `metrics`-крейта — вызывать ровно один
/// раз при старте, до первого `metrics::counter!`/`gauge!`/`histogram!`.
pub fn install_recorder() -> PrometheusHandle {
PrometheusBuilder::new()
.install_recorder()
.expect("Failed to install Prometheus recorder")
}
pub async fn run(host: String, port: u16, handle: PrometheusHandle, token: CancellationToken) {
let addr = format!("{host}:{port}");
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(error = %e, addr = %addr, "Не удалось поднять metrics-листенер");
return;
}
};
info!("📊 Metrics-эндпоинт слушает на {}", addr);
loop {
tokio::select! {
_ = token.cancelled() => {
info!("Metrics-listener остановлен по сигналу отмены.");
break;
}
res = listener.accept() => {
let Ok((mut stream, _)) = res else { continue };
let body = handle.render();
tokio::spawn(async move {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
if let Err(e) = stream.write_all(response.as_bytes()).await {
warn!(error = %e, "Metrics-listener: ошибка записи ответа");
}
});
}
}
}
}
+9 -3
View File
@@ -175,7 +175,9 @@ impl Network {
self.auth.clone(),
);
active_connections.fetch_add(1, Ordering::Relaxed);
let active_now = active_connections.fetch_add(1, Ordering::Relaxed) + 1;
metrics::gauge!("netrunner_connections_active").set(active_now as f64);
metrics::counter!("netrunner_connections_total").increment(1);
let conn_counter = active_connections.clone();
tokio::spawn(async move {
// "Входим" в этот Span. Все логи внутри handler.run() привяжутся к этому IP.
@@ -185,7 +187,8 @@ impl Network {
if let Err(e) = handler.run().await {
error!(error = %e, "⚠️ Server handler terminated with error");
}
conn_counter.fetch_sub(1, Ordering::Relaxed);
let active_now = conn_counter.fetch_sub(1, Ordering::Relaxed) - 1;
metrics::gauge!("netrunner_connections_active").set(active_now as f64);
});
}
}
@@ -207,6 +210,9 @@ impl Network {
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
info!("✅ Drain завершён, соединений осталось: {}", active_connections.load(Ordering::Relaxed));
info!(
"✅ Drain завершён, соединений осталось: {}",
active_connections.load(Ordering::Relaxed)
);
}
}