Единый мониторинг: бизнес-метрики + вынос Prometheus/Loki/Grafana на VPS с данными
Prometheus/Loki/Grafana больше не живут внутри backend-стека — единый
стек теперь на VPS с данными (netrunner-data), т.к. ему нужно видеть
ещё лендинг и все прокси-ноды, не только бэкенд. Отсюда только тонкий
promtail, пушащий логи по сети (LOKI_PUSH_URL).
Новые бизнес-метрики поверх уже существующих generic HTTP-метрик:
payments_total{provider,status}, nodes_online/nodes_total,
support_tickets_total{status}, referral_signups_total,
partner_commission_total{currency} — тем же макросом metrics::counter!/
gauge!, что уже был в api.rs/error.rs.
Новый гейт-эндпоинт GET /api/v1/admin/observability/check (только
Owner/Moderator) — для Caddy forward_auth перед Grafana (см.
netrunner-data/observability/Caddyfile), встройка в админку через
GRAFANA_PUBLIC_URL в GET /api/v1/config.
templates/init_node.sh — добавляет --metrics-port 9093 в провижининг
новых прокси-нод.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+17
-2
@@ -32,6 +32,7 @@ use crate::modules::{
|
||||
proxy_internal,
|
||||
referrals::{controller as ref_ctrl, service::ReferralService},
|
||||
staff::{controller as staff_ctrl, service::StaffService},
|
||||
support::{controller as support_ctrl, service::SupportService},
|
||||
users::{controller as user_ctrl, service::UserService},
|
||||
};
|
||||
|
||||
@@ -43,6 +44,7 @@ pub struct ApiState {
|
||||
pub referral_service: ReferralService,
|
||||
pub user_service: UserService,
|
||||
pub staff_service: StaffService,
|
||||
pub support_service: SupportService,
|
||||
pub jwt_secret: String,
|
||||
pub bot_token: String,
|
||||
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
|
||||
@@ -84,6 +86,7 @@ impl Clone for ApiState {
|
||||
referral_service: self.referral_service.clone(),
|
||||
user_service: self.user_service.clone(),
|
||||
staff_service: self.staff_service.clone(),
|
||||
support_service: self.support_service.clone(),
|
||||
jwt_secret: self.jwt_secret.clone(),
|
||||
bot_token: self.bot_token.clone(),
|
||||
bot_username: self.bot_username.clone(),
|
||||
@@ -144,7 +147,12 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
||||
.nest("/staff", staff_ctrl::router(state.clone()))
|
||||
.nest("/staff", staff_ctrl::owner_router(state.clone()))
|
||||
.nest("/staff", staff_ctrl::staff_router(state.clone()))
|
||||
.nest("/partners", staff_ctrl::partner_router(state.clone()));
|
||||
.nest("/partners", staff_ctrl::partner_router(state.clone()))
|
||||
.nest("/support", support_ctrl::router(state.clone()))
|
||||
.nest(
|
||||
"/observability",
|
||||
staff_ctrl::observability_router(state.clone()),
|
||||
);
|
||||
|
||||
// Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard
|
||||
// (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда
|
||||
@@ -201,7 +209,14 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
||||
/// Публичная (без auth_guard) рантайм-конфигурация для статического SPA —
|
||||
/// см. `ApiState::bot_username` про то, почему это не build-time env фронта.
|
||||
async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse {
|
||||
Json(json!({ "bot_username": state.bot_username }))
|
||||
// Публичный URL Grafana (за auth-гейтом Caddy, см.
|
||||
// netrunner-data/observability/Caddyfile) — тот же паттерн, что и
|
||||
// bot_username выше: читается на сервере заново на каждый запрос, а не
|
||||
// зашивается в SPA-бандл на этапе сборки, т.к. разный на разных стендах.
|
||||
// `None`, пока GRAFANA_PUBLIC_URL не задан — ObservabilityPage.tsx в
|
||||
// этом случае просто не показывает ссылку/iframe.
|
||||
let grafana_url = std::env::var("GRAFANA_PUBLIC_URL").ok();
|
||||
Json(json!({ "bot_username": state.bot_username, "grafana_url": grafana_url }))
|
||||
}
|
||||
|
||||
/// Liveness: процесс жив и отвечает. Не трогает зависимости.
|
||||
|
||||
@@ -492,6 +492,7 @@ impl AuthService {
|
||||
.create_referral(referrer.id, new_user_id)
|
||||
.await
|
||||
.ok();
|
||||
metrics::counter!("referral_signups_total").increment(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -214,6 +214,8 @@ impl BillingService {
|
||||
.repo
|
||||
.create_invoice(user.id, plan_name, provider_name, currency, invoice_amount)
|
||||
.await?;
|
||||
metrics::counter!("payments_total", "provider" => provider_name.to_string(), "status" => "initiated")
|
||||
.increment(1);
|
||||
|
||||
let invoice_data = InvoiceData {
|
||||
invoice_id,
|
||||
@@ -273,6 +275,8 @@ impl BillingService {
|
||||
self.repo
|
||||
.update_invoice_status(invoice_id, "failed", Some(external_tx_id))
|
||||
.await?;
|
||||
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "failed")
|
||||
.increment(1);
|
||||
return Err(AppError::BusinessLogic(
|
||||
"Payment verification failed".into(),
|
||||
));
|
||||
@@ -282,6 +286,8 @@ impl BillingService {
|
||||
self.repo
|
||||
.update_invoice_status(invoice_id, "paid", Some(external_tx_id))
|
||||
.await?;
|
||||
metrics::counter!("payments_total", "provider" => invoice.provider.clone(), "status" => "paid")
|
||||
.increment(1);
|
||||
|
||||
// 5. АКТИВИРУЕМ ТАРИФ
|
||||
self.repo
|
||||
@@ -360,6 +366,8 @@ impl BillingService {
|
||||
percent,
|
||||
)
|
||||
.await?;
|
||||
metrics::counter!("partner_commission_total", "currency" => invoice.currency.clone())
|
||||
.increment(commission as u64);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
middleware,
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
@@ -27,10 +28,24 @@ pub fn router(state: ApiState) -> Router<ApiState> {
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
/// Только Owner: создание модераторов, выдача им права на управление нодами.
|
||||
/// Только Owner/Moderator — гейт для Caddy `forward_auth` перед Grafana
|
||||
/// (см. netrunner-data/observability/Caddyfile). Тело ответа не важно, важен
|
||||
/// только статус: 200 — Caddy пускает дальше в Grafana, 401 — блокирует.
|
||||
/// Бизнес-метрики (выручка, число юзеров) чувствительны — Support/Partner
|
||||
/// сюда не допускаются, поэтому `staff_guard`, а не `support_access_guard`.
|
||||
pub fn observability_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/check", get(|| async { StatusCode::OK }))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), staff_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
}
|
||||
|
||||
/// Только Owner: создание модераторов/саппортов, выдача модераторам права
|
||||
/// на управление нодами.
|
||||
pub fn owner_router(state: ApiState) -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/moderators", post(create_moderator))
|
||||
.route("/support", post(create_support))
|
||||
.route("/users/{id}/permissions", patch(set_permissions))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), owner_guard))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth_guard))
|
||||
@@ -89,6 +104,27 @@ async fn create_moderator(
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSupportPayload {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
async fn create_support(
|
||||
State(state): State<ApiState>,
|
||||
Json(p): Json<CreateSupportPayload>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let user = state
|
||||
.staff_service
|
||||
.create_support(&p.username, &p.password)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePartnerPayload {
|
||||
pub username: String,
|
||||
|
||||
@@ -82,6 +82,9 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
|
||||
}
|
||||
};
|
||||
|
||||
let total = nodes.len();
|
||||
let mut online_count: u64 = 0;
|
||||
|
||||
for node in nodes {
|
||||
if node.status == "provisioning" {
|
||||
continue;
|
||||
@@ -102,6 +105,9 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
|
||||
};
|
||||
|
||||
let new_status = if is_reachable { "online" } else { "offline" };
|
||||
if is_reachable {
|
||||
online_count += 1;
|
||||
}
|
||||
if new_status != node.status {
|
||||
info!(node_id = %node.id, name = %node.name, from = %node.status, to = %new_status, "Смена статуса ноды по health-check");
|
||||
}
|
||||
@@ -110,4 +116,7 @@ async fn run_node_health_check(repo: &Arc<dyn AppRepository>) {
|
||||
error!(node_id = %node.id, error = ?e, "Health-check: не удалось обновить статус ноды");
|
||||
}
|
||||
}
|
||||
|
||||
metrics::gauge!("nodes_online").set(online_count as f64);
|
||||
metrics::gauge!("nodes_total").set(total as f64);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user