use std::fmt::Write as _;
use axum::{Json, extract::State, response::IntoResponse};
use fraiseql_core::db::traits::DatabaseAdapter;
use serde::Serialize;
use tracing::debug;
use crate::{metrics_server::PrometheusMetrics, routes::graphql::AppState};
#[derive(Debug, Serialize)]
pub struct MetricsResponse {
pub queries_total: u64,
pub queries_success: u64,
pub queries_error: u64,
pub avg_query_duration_ms: f64,
pub cache_hit_ratio: f64,
pub pool_connections_total: u32,
pub pool_connections_idle: u32,
pub pool_connections_active: u32,
pub pool_requests_waiting: u32,
}
pub async fn metrics_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
) -> impl IntoResponse {
debug!("Metrics endpoint requested");
let prometheus_metrics = PrometheusMetrics::from(state.metrics.as_ref());
let mut output = prometheus_metrics.to_prometheus_format();
#[cfg(feature = "federation")]
if let Some(ref cb_manager) = state.circuit_breaker {
let states = cb_manager.collect_states();
if !states.is_empty() {
output.push_str(concat!(
"\n# HELP fraiseql_federation_circuit_breaker_state ",
"Federation circuit breaker state per entity type ",
"(0=closed, 1=open, 2=half_open)\n",
"# TYPE fraiseql_federation_circuit_breaker_state gauge\n",
));
for (entity, state_code) in states {
let _ = writeln!(
output,
"fraiseql_federation_circuit_breaker_state{{entity=\"{entity}\"}} \
{state_code}"
);
}
}
}
#[cfg(feature = "federation")]
{
let hist = state.federation_latency.to_prometheus_histogram();
output.push('\n');
output.push_str(&hist);
let counters = state.federation_entity_metrics.to_prometheus_counters();
output.push('\n');
output.push_str(&counters);
}
#[cfg(feature = "redis-rate-limiting")]
{
let errors = crate::middleware::rate_limit::redis_error_count_total();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_rate_limit_redis_errors_total ",
"Total Redis rate limiter fail-open events (Redis unreachable)\n",
"# TYPE fraiseql_rate_limit_redis_errors_total counter\n",
"fraiseql_rate_limit_redis_errors_total {errors}\n",
),
errors = errors
);
}
#[cfg(feature = "redis-pkce")]
{
let errors = fraiseql_auth::pkce::redis_pkce_error_count_total();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_pkce_redis_errors_total ",
"Total Redis PKCE store errors (connection failures, etc.)\n",
"# TYPE fraiseql_pkce_redis_errors_total counter\n",
"fraiseql_pkce_redis_errors_total {errors}\n",
),
errors = errors
);
}
{
let apq = &state.apq_metrics;
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_apq_hits_total Total APQ cache hits\n",
"# TYPE fraiseql_apq_hits_total counter\n",
"fraiseql_apq_hits_total {hits}\n",
"\n# HELP fraiseql_apq_misses_total Total APQ cache misses\n",
"# TYPE fraiseql_apq_misses_total counter\n",
"fraiseql_apq_misses_total {misses}\n",
"\n# HELP fraiseql_apq_stored_total Total APQ queries stored\n",
"# TYPE fraiseql_apq_stored_total counter\n",
"fraiseql_apq_stored_total {stored}\n",
),
hits = apq.get_hits(),
misses = apq.get_misses(),
stored = apq.get_stored(),
);
}
#[cfg(feature = "redis-apq")]
{
let errors = fraiseql_core::apq::redis_storage::redis_apq_error_count_total();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_apq_redis_errors_total ",
"Total Redis APQ fail-open events\n",
"# TYPE fraiseql_apq_redis_errors_total counter\n",
"fraiseql_apq_redis_errors_total {errors}\n",
),
errors = errors
);
}
#[cfg(feature = "mcp")]
{
let calls = crate::mcp::handler::mcp_tool_calls_total();
let errors = crate::mcp::handler::mcp_tool_errors_total();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_mcp_tool_calls_total Total MCP tool calls\n",
"# TYPE fraiseql_mcp_tool_calls_total counter\n",
"fraiseql_mcp_tool_calls_total {calls}\n",
"\n# HELP fraiseql_mcp_tool_errors_total Total MCP tool call errors\n",
"# TYPE fraiseql_mcp_tool_errors_total counter\n",
"fraiseql_mcp_tool_errors_total {errors}\n",
),
calls = calls,
errors = errors,
);
}
if state.trusted_docs.is_some() {
let hits = crate::trusted_documents::hits_total();
let misses = crate::trusted_documents::misses_total();
let rejected = crate::trusted_documents::rejected_total();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_trusted_documents_hits_total Trusted document lookups resolved from manifest\n",
"# TYPE fraiseql_trusted_documents_hits_total counter\n",
"fraiseql_trusted_documents_hits_total {hits}\n",
"\n# HELP fraiseql_trusted_documents_misses_total Unknown document ID lookups\n",
"# TYPE fraiseql_trusted_documents_misses_total counter\n",
"fraiseql_trusted_documents_misses_total {misses}\n",
"\n# HELP fraiseql_trusted_documents_rejected_total Raw queries rejected (strict mode)\n",
"# TYPE fraiseql_trusted_documents_rejected_total counter\n",
"fraiseql_trusted_documents_rejected_total {rejected}\n",
),
hits = hits,
misses = misses,
rejected = rejected,
);
}
{
let pool = state.executor().pool_metrics();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_db_pool_connections_total Total connections in pool\n",
"# TYPE fraiseql_db_pool_connections_total gauge\n",
"fraiseql_db_pool_connections_total {total}\n",
"\n# HELP fraiseql_db_pool_connections_idle Idle (available) connections\n",
"# TYPE fraiseql_db_pool_connections_idle gauge\n",
"fraiseql_db_pool_connections_idle {idle}\n",
"\n# HELP fraiseql_db_pool_connections_active Active (in-use) connections\n",
"# TYPE fraiseql_db_pool_connections_active gauge\n",
"fraiseql_db_pool_connections_active {active}\n",
"\n# HELP fraiseql_db_pool_requests_waiting Requests waiting for a connection\n",
"# TYPE fraiseql_db_pool_requests_waiting gauge\n",
"fraiseql_db_pool_requests_waiting {waiting}\n",
),
total = pool.total_connections,
idle = pool.idle_connections,
active = pool.active_connections,
waiting = pool.waiting_requests,
);
}
if let Ok(stats) = state.executor().adapter().query_stats(5).await {
if !stats.is_empty() {
output.push_str(concat!(
"\n# HELP fraiseql_db_query_exec_seconds ",
"Cumulative execution time per query (from DB stats extension)\n",
"# TYPE fraiseql_db_query_exec_seconds gauge\n",
));
for entry in &stats {
let query_short = truncate_label(&entry.query_text, 80);
let _ = writeln!(
output,
"fraiseql_db_query_exec_seconds{{queryid=\"{}\",query=\"{}\"}} {:.6}",
entry.query_id,
query_short,
entry.total_exec_time_ms / 1000.0
);
}
output.push_str(concat!(
"\n# HELP fraiseql_db_query_calls ",
"Total call count per query (from DB stats extension)\n",
"# TYPE fraiseql_db_query_calls gauge\n",
));
for entry in &stats {
let query_short = truncate_label(&entry.query_text, 80);
let _ = writeln!(
output,
"fraiseql_db_query_calls{{queryid=\"{}\",query=\"{}\"}} {}",
entry.query_id, query_short, entry.calls
);
}
output.push_str(concat!(
"\n# HELP fraiseql_db_query_mean_exec_seconds ",
"Mean execution time per query (from DB stats extension)\n",
"# TYPE fraiseql_db_query_mean_exec_seconds gauge\n",
));
for entry in &stats {
let query_short = truncate_label(&entry.query_text, 80);
let _ = writeln!(
output,
"fraiseql_db_query_mean_exec_seconds{{queryid=\"{}\",query=\"{}\"}} {:.6}",
entry.query_id,
query_short,
entry.mean_exec_time_ms / 1000.0
);
}
output.push_str(concat!(
"\n# HELP fraiseql_db_cache_hit_ratio ",
"Buffer cache hit ratio per query (0-1, from DB stats extension)\n",
"# TYPE fraiseql_db_cache_hit_ratio gauge\n",
));
for entry in &stats {
if let Some(ratio) = entry.cache_hit_ratio {
let query_short = truncate_label(&entry.query_text, 80);
let _ = writeln!(
output,
"fraiseql_db_cache_hit_ratio{{queryid=\"{}\",query=\"{}\"}} {:.4}",
entry.query_id, query_short, ratio
);
}
}
}
}
if let Some(ref tuner) = state.pool_tuner {
let adjustments = tuner.adjustments_total();
let recommended = tuner.recommended_size();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_pool_tuning_adjustments_total ",
"Total pool resize operations applied or recommended\n",
"# TYPE fraiseql_pool_tuning_adjustments_total counter\n",
"fraiseql_pool_tuning_adjustments_total {adjustments}\n",
"\n# HELP fraiseql_pool_recommended_size ",
"Current recommended connection pool size\n",
"# TYPE fraiseql_pool_recommended_size gauge\n",
"fraiseql_pool_recommended_size {recommended}\n",
),
adjustments = adjustments,
recommended = recommended,
);
}
{
let reloads = state.metrics.schema_reloads_total.load(std::sync::atomic::Ordering::Relaxed);
let errors = state
.metrics
.schema_reload_errors_total
.load(std::sync::atomic::Ordering::Relaxed);
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_schema_reloads_total Total successful schema reloads\n",
"# TYPE fraiseql_schema_reloads_total counter\n",
"fraiseql_schema_reloads_total {reloads}\n",
"\n# HELP fraiseql_schema_reload_errors_total Total failed schema reload attempts\n",
"# TYPE fraiseql_schema_reload_errors_total counter\n",
"fraiseql_schema_reload_errors_total {errors}\n",
),
reloads = reloads,
errors = errors,
);
}
output.push_str(&state.metrics.operation_metrics.to_prometheus_format());
output.push_str(&state.metrics.http_request_duration.to_prometheus_lines(
"fraiseql_http_request_duration_seconds",
"HTTP request duration in seconds",
));
output.push_str(&state.metrics.db_query_duration.to_prometheus_lines(
"fraiseql_database_query_duration_seconds",
"Database query duration in seconds",
));
{
let rejected = fraiseql_core::security::oidc::jwt_replay_rejected_total();
let errors = fraiseql_core::security::oidc::jwt_replay_cache_errors_total();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_jwt_replay_rejected_total ",
"Total JWT replay attempts rejected (duplicate jti)\n",
"# TYPE fraiseql_jwt_replay_rejected_total counter\n",
"fraiseql_jwt_replay_rejected_total {rejected}\n",
"\n# HELP fraiseql_jwt_replay_cache_errors_total ",
"Total JWT replay cache backend errors (fail-open events)\n",
"# TYPE fraiseql_jwt_replay_cache_errors_total counter\n",
"fraiseql_jwt_replay_cache_errors_total {errors}\n",
),
rejected = rejected,
errors = errors,
);
}
{
let multi_root = fraiseql_core::runtime::multi_root_queries_total();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_multi_root_queries_total ",
"Total multi-root GraphQL queries dispatched via parallel execution\n",
"# TYPE fraiseql_multi_root_queries_total counter\n",
"fraiseql_multi_root_queries_total {multi_root}\n",
),
multi_root = multi_root,
);
}
let subs = crate::routes::subscription_metrics();
let _ = write!(
output,
concat!(
"\n# HELP fraiseql_ws_connections_total Total WebSocket subscription connections\n",
"# TYPE fraiseql_ws_connections_total counter\n",
"fraiseql_ws_connections_total{{result=\"accepted\"}} {accepted}\n",
"fraiseql_ws_connections_total{{result=\"rejected\"}} {rejected}\n",
"\n# HELP fraiseql_ws_subscriptions_total Total subscription registrations\n",
"# TYPE fraiseql_ws_subscriptions_total counter\n",
"fraiseql_ws_subscriptions_total{{result=\"accepted\"}} {sub_accepted}\n",
"fraiseql_ws_subscriptions_total{{result=\"rejected\"}} {sub_rejected}\n",
),
accepted = subs.connections_accepted,
rejected = subs.connections_rejected,
sub_accepted = subs.subscriptions_accepted,
sub_rejected = subs.subscriptions_rejected,
);
#[cfg(feature = "metrics")]
{
let facade = crate::metrics_recorder::render();
if !facade.is_empty() {
output.push('\n');
output.push_str(&facade);
}
}
(
axum::http::StatusCode::OK,
[("Content-Type", "text/plain; version=0.0.4")],
output,
)
}
fn truncate_label(s: &str, max_chars: usize) -> String {
let truncated = if s.len() > max_chars {
&s[..max_chars]
} else {
s
};
truncated.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
}
pub async fn metrics_json_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
) -> impl IntoResponse {
debug!("JSON metrics endpoint requested");
let prometheus_metrics = PrometheusMetrics::from(state.metrics.as_ref());
let pool = state.executor().pool_metrics();
let response = MetricsResponse {
queries_total: prometheus_metrics.queries_total,
queries_success: prometheus_metrics.queries_success,
queries_error: prometheus_metrics.queries_error,
avg_query_duration_ms: prometheus_metrics.queries_avg_duration_ms,
cache_hit_ratio: prometheus_metrics.cache_hit_ratio,
pool_connections_total: pool.total_connections,
pool_connections_idle: pool.idle_connections,
pool_connections_active: pool.active_connections,
pool_requests_waiting: pool.waiting_requests,
};
Json(response)
}