Files
foxhunt/services/api/src/metrics/exporter.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
Copied from api_gateway, removed REST handlers (port 8080),
added tonic-web + CORS for grpc-web browser access.
Binary renamed: api-gateway → api

Changes:
- Package name: api-gateway → api
- Deleted src/handlers/ (REST ML endpoints on port 8080)
- Added tonic-web 0.13 + tower-http CORS layer
- Server::builder().accept_http1(true) for grpc-web
- CORS_ORIGINS env var (default http://localhost:5173)
- Metrics server on port 9091 (axum) preserved
- All 95 lib tests pass, 0 clippy warnings
- Added services/api to workspace members

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +01:00

137 lines
4.2 KiB
Rust

//! Prometheus Metrics Exporter
//!
//! Provides HTTP endpoint for Prometheus scraping at /metrics
use prometheus::{Encoder, Registry, TextEncoder};
use std::sync::Arc;
use tonic::{Response, Status};
/// Prometheus exporter for metrics collection
#[derive(Clone)]
pub struct PrometheusExporter {
registry: Arc<Registry>,
}
impl PrometheusExporter {
/// Create new exporter with registry
pub fn new(registry: Arc<Registry>) -> Self {
Self { registry }
}
/// Gather and encode metrics in Prometheus text format
pub fn gather_metrics(&self) -> Result<String, prometheus::Error> {
let encoder = TextEncoder::new();
let metric_families = self.registry.gather();
let mut buffer = vec![];
encoder.encode(&metric_families, &mut buffer)?;
String::from_utf8(buffer).map_err(|e| {
prometheus::Error::Msg(format!("Failed to convert metrics to UTF-8: {}", e))
})
}
/// Export metrics as `HTTP` response (for Axum/Hyper integration)
pub fn export_http(&self) -> Result<Vec<u8>, prometheus::Error> {
let encoder = TextEncoder::new();
let metric_families = self.registry.gather();
let mut buffer = vec![];
encoder.encode(&metric_families, &mut buffer)?;
Ok(buffer)
}
}
/// Create Prometheus metrics endpoint (for standalone `HTTP` server)
///
/// Usage with Axum:
/// ```rust,ignore
/// use axum::{Router, routing::get};
/// use api::metrics::{GatewayMetrics, metrics_router};
///
/// let metrics = GatewayMetrics::new()?;
/// let router = metrics_router(metrics.registry());
/// ```
pub fn metrics_router(registry: Arc<Registry>) -> axum::Router {
use axum::{routing::get, Router};
let exporter = PrometheusExporter::new(registry);
Router::new().route(
"/metrics",
get(move || {
let exporter = exporter.clone();
async move {
match exporter.gather_metrics() {
Ok(metrics) => Ok(metrics),
Err(e) => Err(format!("Failed to gather metrics: {}", e)),
}
}
}),
)
}
/// Create combined metrics and health router
///
/// This combines Prometheus metrics with health/resilience endpoints
pub fn combined_router(registry: Arc<Registry>) -> axum::Router {
use crate::health_router::{health_router, HealthState};
use axum::Router;
let metrics_routes = metrics_router(registry);
let health_state = HealthState::new();
let health_routes = health_router(health_state);
Router::new().merge(metrics_routes).merge(health_routes)
}
/// Create Prometheus endpoint as gRPC health check extension
///
/// This allows exposing metrics through the same gRPC server
pub async fn serve_metrics_grpc(registry: Arc<Registry>) -> Result<Response<String>, Status> {
let exporter = PrometheusExporter::new(registry);
exporter
.gather_metrics()
.map(Response::new)
.map_err(|e| Status::internal(format!("Failed to gather metrics: {}", e)))
}
#[cfg(test)]
mod tests {
use super::*;
use prometheus::{Counter, Opts};
#[test]
fn test_prometheus_exporter() {
let registry = Arc::new(Registry::new());
// Register test counter
let counter = Counter::with_opts(Opts::new("test_counter", "Test counter")).unwrap();
registry.register(Box::new(counter.clone())).unwrap();
counter.inc();
counter.inc();
let exporter = PrometheusExporter::new(registry);
let metrics = exporter.gather_metrics().unwrap();
assert!(metrics.contains("test_counter"));
assert!(metrics.contains("# HELP test_counter Test counter"));
assert!(metrics.contains("test_counter 2"));
}
#[test]
fn test_http_export() {
let registry = Arc::new(Registry::new());
let counter = Counter::with_opts(Opts::new("http_test", "HTTP test")).unwrap();
registry.register(Box::new(counter.clone())).unwrap();
counter.inc_by(42.0);
let exporter = PrometheusExporter::new(registry);
let buffer = exporter.export_http().unwrap();
let metrics = String::from_utf8(buffer).expect("INVARIANT: Valid UTF-8 bytes");
assert!(metrics.contains("http_test"));
assert!(metrics.contains("42"));
}
}