use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::ack::{self, AckAction, AckEntry, AckError, AckStore};
use super::findings_store::{FindingsFilter, FindingsStore, StoredFinding};
use crate::acknowledgments::{Acknowledgment, compute_signature};
use crate::correlate::Trace;
use crate::correlate::window::TraceWindow;
use crate::detect::correlate_cross::{CrossTraceCorrelation, CrossTraceCorrelator};
#[cfg(test)]
use crate::detect::sanitizer_aware::SanitizerAwareMode;
use crate::detect::{self, DetectConfig};
use crate::explain;
use crate::report::metrics::{AckFailureReason, MetricsState};
use crate::report::{Analysis, GreenSummary, QualityGate, Report};
#[doc(hidden)]
pub const MAX_FINDINGS_LIMIT: usize = 1000;
const MAX_CORRELATIONS_LIMIT: usize = 1000;
#[doc(hidden)]
pub const MAX_ACKS_RESPONSE: usize = 1000;
pub struct QueryApiState {
pub findings_store: Arc<FindingsStore>,
pub window: Arc<tokio::sync::Mutex<TraceWindow>>,
pub detect_config: DetectConfig,
pub start_time: std::time::Instant,
pub correlator: Option<Arc<tokio::sync::Mutex<CrossTraceCorrelator>>>,
pub metrics: Arc<MetricsState>,
pub scoring_config: Option<crate::score::carbon::ScoringConfig>,
pub green_summary: Arc<tokio::sync::RwLock<GreenSummary>>,
pub ack_store: Option<Arc<AckStore>>,
pub toml_acks: Arc<HashMap<String, ResolvedTomlAck>>,
pub ack_api_key: Option<String>,
pub daemon_config: crate::config::DaemonConfig,
pub energy_backends: EnergyBackendsConfigured,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, Default)]
pub struct EnergyBackendsConfigured {
pub scaphandre: bool,
pub kepler: bool,
pub redfish: bool,
pub cloud_energy: bool,
}
pub fn query_api_router(state: Arc<QueryApiState>) -> Router {
Router::new()
.route("/api/findings", get(handle_findings))
.route("/api/findings/{trace_id}", get(handle_findings_by_trace))
.route("/api/explain/{trace_id}", get(handle_explain))
.route("/api/correlations", get(handle_correlations))
.route("/api/status", get(handle_status))
.route("/api/config", get(handle_config))
.route("/api/energy", get(handle_energy))
.route("/api/export/report", get(handle_export_report))
.route(
"/api/findings/{signature}/ack",
post(handle_ack).delete(handle_unack),
)
.route("/api/acks", get(handle_list_acks))
.with_state(state)
}
#[derive(Deserialize, Default)]
struct FindingsParams {
service: Option<String>,
#[serde(rename = "type")]
finding_type: Option<String>,
severity: Option<String>,
limit: Option<usize>,
#[serde(default)]
include_acked: bool,
}
#[derive(Debug, Deserialize, Default)]
struct AckRequest {
by: Option<String>,
reason: Option<String>,
expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct ResolvedTomlAck {
pub inner: Acknowledgment,
pub expires_at_dt: Option<DateTime<Utc>>,
}
impl ResolvedTomlAck {
#[must_use]
pub fn is_active(&self, now: DateTime<Utc>) -> bool {
self.expires_at_dt.is_none_or(|e| e >= now)
}
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum AckSource {
Toml {
acknowledged_by: String,
acknowledged_at: String,
reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
},
Daemon {
by: String,
at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<DateTime<Utc>>,
},
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct FindingResponse {
#[serde(flatten)]
pub stored: StoredFinding,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acknowledged_by: Option<AckSource>,
}
#[derive(Serialize)]
struct StatusResponse {
version: &'static str,
uptime_seconds: u64,
active_traces: usize,
max_active_traces: usize,
analysis_queue_depth: i64,
analysis_queue_capacity: usize,
stored_findings: usize,
max_retained_findings: usize,
}
async fn handle_findings(
State(state): State<Arc<QueryApiState>>,
Query(params): Query<FindingsParams>,
) -> Json<Vec<FindingResponse>> {
let include_acked = params.include_acked;
let filter = FindingsFilter {
service: params.service,
finding_type: params.finding_type,
severity: params.severity,
limit: params.limit.unwrap_or(100).min(MAX_FINDINGS_LIMIT),
};
let stored = state.findings_store.query(&filter).await;
let daemon_snapshot: Arc<HashMap<String, AckEntry>> = match &state.ack_store {
Some(s) => s.snapshot_active().await,
None => Arc::new(HashMap::new()),
};
let now = Utc::now();
let result: Vec<FindingResponse> = stored
.into_iter()
.filter_map(|s| {
let owned_sig: String;
let sig: &str = if s.finding.signature.is_empty() {
tracing::warn!(
finding_type = s.finding.finding_type.as_str(),
"stored finding had empty signature, recomputing on the read path"
);
owned_sig = compute_signature(&s.finding);
&owned_sig
} else {
&s.finding.signature
};
let ack = lookup_ack(sig, &state.toml_acks, &daemon_snapshot, now);
match (include_acked, ack) {
(false, Some(_)) => None,
(false, None) => Some(FindingResponse {
stored: s,
acknowledged_by: None,
}),
(true, src) => Some(FindingResponse {
stored: s,
acknowledged_by: src,
}),
}
})
.collect();
Json(result)
}
fn lookup_ack(
signature: &str,
toml: &HashMap<String, ResolvedTomlAck>,
daemon: &HashMap<String, AckEntry>,
now: DateTime<Utc>,
) -> Option<AckSource> {
if let Some(t) = toml.get(signature)
&& t.is_active(now)
{
return Some(AckSource::Toml {
acknowledged_by: t.inner.acknowledged_by.clone(),
acknowledged_at: t.inner.acknowledged_at.clone(),
reason: t.inner.reason.clone(),
expires_at: t.inner.expires_at.clone(),
});
}
if let Some(d) = daemon.get(signature)
&& !ack::is_expired(d, now)
{
return Some(AckSource::Daemon {
by: d.by.clone(),
at: d.at,
reason: d.reason.clone(),
expires_at: d.expires_at,
});
}
None
}
async fn handle_findings_by_trace(
State(state): State<Arc<QueryApiState>>,
Path(trace_id): Path<String>,
) -> Json<Vec<StoredFinding>> {
let mut results = state.findings_store.by_trace_id(&trace_id).await;
results.truncate(MAX_FINDINGS_LIMIT);
Json(results)
}
async fn handle_explain(
State(state): State<Arc<QueryApiState>>,
Path(trace_id): Path<String>,
) -> Json<serde_json::Value> {
let maybe_spans = {
let window = state.window.lock().await;
window.peek_clone(&trace_id)
};
let value = match maybe_spans {
Some(spans) => {
let trace = Trace {
trace_id: trace_id.clone(),
spans,
};
let findings = detect::detect(std::slice::from_ref(&trace), &state.detect_config);
let tree = explain::build_tree(&trace, &findings);
serde_json::to_value(&tree)
.unwrap_or_else(|_| serde_json::json!({"error": "failed to format explain tree"}))
}
None => serde_json::json!({"error": "trace not found in daemon memory"}),
};
Json(value)
}
async fn handle_correlations(
State(state): State<Arc<QueryApiState>>,
) -> Json<Vec<CrossTraceCorrelation>> {
match &state.correlator {
Some(correlator) => {
let mut correlations = correlator.lock().await.active_correlations();
correlations.sort_by(|a, b| {
b.confidence
.total_cmp(&a.confidence)
.then_with(|| b.co_occurrence_count.cmp(&a.co_occurrence_count))
});
correlations.truncate(MAX_CORRELATIONS_LIMIT);
Json(correlations)
}
None => Json(vec![]),
}
}
async fn handle_status(State(state): State<Arc<QueryApiState>>) -> Json<StatusResponse> {
let uptime = state.start_time.elapsed().as_secs();
let active_traces = state.window.lock().await.active_traces();
let stored_findings = state.findings_store.len().await;
Json(StatusResponse {
version: env!("CARGO_PKG_VERSION"),
uptime_seconds: uptime,
active_traces,
max_active_traces: state.daemon_config.max_active_traces,
analysis_queue_depth: state.metrics.analysis_queue_depth.get(),
analysis_queue_capacity: state.daemon_config.analysis_queue_capacity,
stored_findings,
max_retained_findings: state.daemon_config.max_retained_findings,
})
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)]
struct ConfigResponse {
listen_addr: String,
listen_port: u16,
listen_port_grpc: u16,
json_socket: String,
max_active_traces: usize,
trace_ttl_ms: u64,
sampling_rate: f64,
max_events_per_trace: usize,
max_payload_size: usize,
environment: &'static str,
max_retained_findings: usize,
ingest_queue_capacity: usize,
analysis_queue_capacity: usize,
memory_high_water_pct: u8,
api_enabled: bool,
tls_configured: bool,
ack_enabled: bool,
ack_api_key_set: bool,
cors_allowed_origins: Vec<String>,
archive_configured: bool,
correlation_enabled: bool,
correlation_window_ms: u64,
correlation_lag_threshold_ms: u64,
correlation_min_co_occurrences: u32,
correlation_min_confidence: f64,
correlation_max_tracked_pairs: usize,
}
async fn handle_config(State(state): State<Arc<QueryApiState>>) -> Json<ConfigResponse> {
let d = &state.daemon_config;
Json(ConfigResponse {
listen_addr: d.listen_addr.clone(),
listen_port: d.listen_port,
listen_port_grpc: d.listen_port_grpc,
json_socket: d.json_socket.clone(),
max_active_traces: d.max_active_traces,
trace_ttl_ms: d.trace_ttl_ms,
sampling_rate: d.sampling_rate,
max_events_per_trace: d.max_events_per_trace,
max_payload_size: d.max_payload_size,
environment: d.environment.as_str(),
max_retained_findings: d.max_retained_findings,
ingest_queue_capacity: d.ingest_queue_capacity,
analysis_queue_capacity: d.analysis_queue_capacity,
memory_high_water_pct: d.memory_high_water_pct,
api_enabled: d.api_enabled,
tls_configured: d.tls.cert_path.is_some() && d.tls.key_path.is_some(),
ack_enabled: d.ack.enabled,
ack_api_key_set: d.ack.api_key.is_some(),
cors_allowed_origins: d.cors.allowed_origins.clone(),
archive_configured: d.archive.is_some(),
correlation_enabled: d.correlation.enabled,
correlation_window_ms: d.correlation.window_ms,
correlation_lag_threshold_ms: d.correlation.lag_threshold_ms,
correlation_min_co_occurrences: d.correlation.min_co_occurrences,
correlation_min_confidence: d.correlation.min_confidence,
correlation_max_tracked_pairs: d.correlation.max_tracked_pairs,
})
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnergyBackendStatus {
pub backend: String,
pub configured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_scrape_age_seconds: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scrapes_ok: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scrapes_failed: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnergyStatusResponse {
pub backends: Vec<EnergyBackendStatus>,
}
async fn handle_energy(State(state): State<Arc<QueryApiState>>) -> Json<EnergyStatusResponse> {
type CounterPair<'a> = (&'a prometheus::IntCounter, &'a prometheus::IntCounter);
let m = &state.metrics;
let b = state.energy_backends;
let rows: [(&str, bool, Option<&prometheus::Gauge>, Option<CounterPair>); 5] = [
(
"scaphandre",
b.scaphandre,
Some(&m.scaphandre_last_scrape_age_seconds),
Some((&m.scaphandre_scrape_success, &m.scaphandre_scrape_failed)),
),
(
"kepler",
b.kepler,
Some(&m.kepler_last_scrape_age_seconds),
Some((&m.kepler_scrape_success, &m.kepler_scrape_failed)),
),
(
"redfish",
b.redfish,
Some(&m.redfish_last_scrape_age_seconds),
Some((&m.redfish_scrape_success, &m.redfish_scrape_failed)),
),
(
"cloud_energy",
b.cloud_energy,
Some(&m.cloud_energy_last_scrape_age_seconds),
None,
),
(
"electricity_maps",
state.scoring_config.is_some(),
None,
None,
),
];
let backends = rows
.into_iter()
.map(|(name, configured, age, counters)| EnergyBackendStatus {
backend: name.to_string(),
configured,
last_scrape_age_seconds: if configured {
age.map(prometheus::Gauge::get)
} else {
None
},
scrapes_ok: if configured {
counters.map(|(ok, _)| ok.get())
} else {
None
},
scrapes_failed: if configured {
counters.map(|(_, failed)| failed.get())
} else {
None
},
})
.collect();
Json(EnergyStatusResponse { backends })
}
async fn handle_export_report(State(state): State<Arc<QueryApiState>>) -> Json<Report> {
state.metrics.export_report_requests_total.inc();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let events_processed = state.metrics.events_processed_total.get() as u64;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let traces_analyzed = state.metrics.traces_analyzed_total.get() as u64;
if events_processed == 0 || traces_analyzed == 0 {
let mut green_summary = GreenSummary::disabled(0);
green_summary
.scoring_config
.clone_from(&state.scoring_config);
return Json(Report {
analysis: Analysis {
duration_ms: 0,
events_processed: 0,
traces_analyzed: 0,
},
findings: Vec::new(),
green_summary,
quality_gate: QualityGate {
passed: true,
rules: Vec::new(),
},
per_endpoint_io_ops: Vec::new(),
correlations: Vec::new(),
warnings: vec!["daemon has not yet processed any events".to_string()],
warning_details: vec![crate::report::Warning::new(
crate::report::warnings::COLD_START,
"daemon has not yet processed any events",
)],
acknowledged_findings: Vec::new(),
binary_version: env!("CARGO_PKG_VERSION").to_string(),
disclosure_waste: None,
});
}
let stored = state
.findings_store
.query(&FindingsFilter {
service: None,
finding_type: None,
severity: None,
limit: MAX_FINDINGS_LIMIT,
})
.await;
let findings: Vec<_> = stored.into_iter().map(|s| s.finding).collect();
let correlations = if let Some(correlator) = &state.correlator {
let mut list = correlator.lock().await.active_correlations();
list.sort_by(|a, b| {
b.confidence
.total_cmp(&a.confidence)
.then_with(|| b.co_occurrence_count.cmp(&a.co_occurrence_count))
});
list.truncate(MAX_CORRELATIONS_LIMIT);
list
} else {
vec![]
};
let mut green_summary = state.green_summary.read().await.clone();
green_summary
.scoring_config
.clone_from(&state.scoring_config);
let quality_gate = QualityGate {
passed: true,
rules: vec![],
};
let events_usize = usize::try_from(events_processed).unwrap_or_else(|_| {
tracing::warn!(
counter = events_processed,
"events_processed overflowed usize on this target, saturating in export"
);
usize::MAX
});
let traces_usize = usize::try_from(traces_analyzed).unwrap_or_else(|_| {
tracing::warn!(
counter = traces_analyzed,
"traces_analyzed overflowed usize on this target, saturating in export"
);
usize::MAX
});
let warning_details = collect_warning_details(&state.metrics, &state.daemon_config);
let report = Report {
analysis: Analysis {
duration_ms: 0,
events_processed: events_usize,
traces_analyzed: traces_usize,
},
findings,
green_summary,
quality_gate,
per_endpoint_io_ops: vec![],
correlations,
warnings: vec![],
warning_details,
acknowledged_findings: vec![],
binary_version: env!("CARGO_PKG_VERSION").to_string(),
disclosure_waste: None,
};
Json(report)
}
fn check_ack_preconditions<'a>(
state: &'a Arc<QueryApiState>,
headers: &HeaderMap,
action: AckAction,
) -> Result<&'a Arc<AckStore>, ErrorResponse> {
if let Err(e) = check_ack_auth(headers, state.ack_api_key.as_deref()) {
state
.metrics
.record_ack_failure(action, AckFailureReason::Unauthorized);
return Err(e);
}
let Some(store) = state.ack_store.as_ref() else {
state
.metrics
.record_ack_failure(action, AckFailureReason::NoStore);
return Err(ErrorResponse::new(
StatusCode::SERVICE_UNAVAILABLE,
"ack store disabled",
));
};
Ok(store)
}
async fn handle_ack(
State(state): State<Arc<QueryApiState>>,
Path(signature): Path<String>,
headers: HeaderMap,
Json(body): Json<AckRequest>,
) -> Result<StatusCode, ErrorResponse> {
let store = check_ack_preconditions(&state, &headers, AckAction::Ack)?;
if let Some(t) = state.toml_acks.get(&signature)
&& t.is_active(Utc::now())
{
state
.metrics
.record_ack_failure(AckAction::Ack, AckFailureReason::AlreadyAcked);
return Err(ErrorResponse::new(
StatusCode::CONFLICT,
"signature is acked by the CI TOML baseline, edit the file via PR review",
));
}
let by = resolve_by(&headers, body.by.as_deref());
let entry = AckEntry {
action: AckAction::Ack,
signature,
by,
reason: body.reason,
at: Utc::now(),
expires_at: body.expires_at,
};
match store.ack(entry).await {
Ok(()) => {
state.metrics.record_ack_success(AckAction::Ack);
Ok(StatusCode::CREATED)
}
Err(AckError::AlreadyAcked) => {
state
.metrics
.record_ack_failure(AckAction::Ack, AckFailureReason::AlreadyAcked);
Err(ErrorResponse::new(StatusCode::CONFLICT, "already acked"))
}
Err(AckError::InvalidSignature) => {
state
.metrics
.record_ack_failure(AckAction::Ack, AckFailureReason::InvalidSignature);
Err(ErrorResponse::new(
StatusCode::BAD_REQUEST,
"invalid signature format",
))
}
Err(AckError::LimitReached) => {
state
.metrics
.record_ack_failure(AckAction::Ack, AckFailureReason::LimitReached);
Err(ErrorResponse::new(
StatusCode::INSUFFICIENT_STORAGE,
"active ack limit reached",
))
}
Err(AckError::FileTooLarge) => {
state
.metrics
.record_ack_failure(AckAction::Ack, AckFailureReason::FileTooLarge);
Err(ErrorResponse::new(
StatusCode::INSUFFICIENT_STORAGE,
"ack file size cap reached",
))
}
Err(AckError::EntryTooLarge) => {
state
.metrics
.record_ack_failure(AckAction::Ack, AckFailureReason::EntryTooLarge);
Err(ErrorResponse::new(
StatusCode::INSUFFICIENT_STORAGE,
"ack entry size cap reached",
))
}
Err(e) => {
state
.metrics
.record_ack_failure(AckAction::Ack, AckFailureReason::InternalError);
tracing::error!(error = %e, "ack store write failed");
Err(ErrorResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
"ack store write failed",
))
}
}
}
async fn handle_unack(
State(state): State<Arc<QueryApiState>>,
Path(signature): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ErrorResponse> {
let store = check_ack_preconditions(&state, &headers, AckAction::Unack)?;
let by = resolve_by(&headers, None);
match store.unack(&signature, &by).await {
Ok(()) => {
state.metrics.record_ack_success(AckAction::Unack);
Ok(StatusCode::NO_CONTENT)
}
Err(AckError::NotAcked) => {
state
.metrics
.record_ack_failure(AckAction::Unack, AckFailureReason::NotAcked);
Err(ErrorResponse::new(StatusCode::NOT_FOUND, "not acked"))
}
Err(AckError::InvalidSignature) => {
state
.metrics
.record_ack_failure(AckAction::Unack, AckFailureReason::InvalidSignature);
Err(ErrorResponse::new(
StatusCode::BAD_REQUEST,
"invalid signature format",
))
}
Err(e) => {
state
.metrics
.record_ack_failure(AckAction::Unack, AckFailureReason::InternalError);
tracing::error!(error = %e, "ack store unack failed");
Err(ErrorResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
"ack store write failed",
))
}
}
}
async fn handle_list_acks(State(state): State<Arc<QueryApiState>>) -> Json<Vec<AckEntry>> {
let mut all = match &state.ack_store {
Some(s) => s.list_active().await,
None => Vec::new(),
};
all.truncate(MAX_ACKS_RESPONSE);
Json(all)
}
fn resolve_by(headers: &HeaderMap, body_by: Option<&str>) -> String {
let raw = headers
.get("X-User-Id")
.and_then(|v| v.to_str().ok())
.map(str::to_string)
.or_else(|| body_by.map(str::to_string))
.unwrap_or_else(|| "anonymous".to_string());
crate::text_safety::strip_bidi_and_invisible(&raw).into_owned()
}
fn check_ack_auth(headers: &HeaderMap, expected: Option<&str>) -> Result<(), ErrorResponse> {
use subtle::ConstantTimeEq;
let Some(expected_key) = expected else {
return Ok(());
};
let provided = headers
.get(crate::http_client::API_KEY_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if provided.as_bytes().ct_eq(expected_key.as_bytes()).into() {
Ok(())
} else {
Err(ErrorResponse::new(
StatusCode::UNAUTHORIZED,
"missing or invalid X-API-Key",
))
}
}
struct ErrorResponse {
status: StatusCode,
message: &'static str,
}
impl ErrorResponse {
const fn new(status: StatusCode, message: &'static str) -> Self {
Self { status, message }
}
}
impl IntoResponse for ErrorResponse {
fn into_response(self) -> Response {
let body = serde_json::json!({"error": self.message});
(self.status, Json(body)).into_response()
}
}
const TUNING_ACTIVE_TRACES_RATIO: f64 = 0.9;
const TUNING_ZERO_RETENTION_MIN_RECEIVED: u64 = 1_000;
fn instrumentation_gap_filtered(metrics: &MetricsState) -> u64 {
use crate::report::metrics::OtlpSpanFilterReason;
OtlpSpanFilterReason::ALL
.iter()
.filter(|r| !matches!(r, OtlpSpanFilterReason::NonSqlDatastore))
.map(|r| {
metrics
.otlp_spans_filtered_total
.with_label_values(&[r.as_str()])
.get()
})
.sum()
}
#[allow(clippy::too_many_lines)]
fn collect_warning_details(
metrics: &MetricsState,
daemon: &crate::config::DaemonConfig,
) -> Vec<crate::report::Warning> {
use crate::report::warnings::{INGESTION_DROPS, TUNING};
let mut details = Vec::new();
let dropped = metrics.otlp_rejected_channel_full.get();
if dropped > 0 {
details.push(crate::report::Warning::new(
INGESTION_DROPS,
format!(
"{dropped} OTLP requests rejected since daemon start \
(channel saturation, see perf_sentinel_otlp_rejected_total)"
),
));
let cap = daemon.ingest_queue_capacity;
details.push(crate::report::Warning::new(
TUNING,
format!(
"{dropped} OTLP requests hit a full ingest queue: raise \
[daemon] ingest_queue_capacity (currently {cap}) or \
spread ingestion across more daemons"
),
));
}
let mem_rejected = metrics.otlp_rejected_memory_pressure.get();
if mem_rejected > 0 {
let pct = daemon.memory_high_water_pct;
details.push(crate::report::Warning::new(
INGESTION_DROPS,
format!(
"{mem_rejected} OTLP requests rejected since daemon start \
(memory high-water, RSS bounded to protect against OOM)"
),
));
details.push(crate::report::Warning::new(
TUNING,
format!(
"{mem_rejected} OTLP requests hit the memory guard \
([daemon] memory_high_water_pct = {pct}): raise the \
container memory limit or spread ingestion across more daemons"
),
));
}
let shed = metrics.analysis_shed_batches_total.get();
if shed > 0 {
let cap = daemon.analysis_queue_capacity;
details.push(crate::report::Warning::new(
TUNING,
format!(
"analysis worker shed {shed} batches since daemon start: \
raise [daemon] analysis_queue_capacity (currently {cap}) \
or give the daemon more CPU so detection keeps up"
),
));
}
#[allow(clippy::cast_precision_loss)]
let active_cap = daemon.max_active_traces as f64;
let active = metrics.active_traces.get();
if active >= active_cap * TUNING_ACTIVE_TRACES_RATIO {
let cap = daemon.max_active_traces;
let ttl = daemon.trace_ttl_ms;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pct = (TUNING_ACTIVE_TRACES_RATIO * 100.0).round() as u32;
details.push(crate::report::Warning::new(
TUNING,
format!(
"active traces ({active:.0}) are within {pct}% of [daemon] \
max_active_traces ({cap}): raise the cap or lower \
trace_ttl_ms (currently {ttl} ms) so LRU eviction does \
not split live traces"
),
));
}
let overflow = metrics.service_io_ops_overflow_total.get();
if overflow > 0 {
let cap = super::event_loop::MAX_SERVICE_CARDINALITY;
details.push(crate::report::Warning::new(
TUNING,
format!(
"{overflow} I/O operations landed beyond the {cap}-service \
metering cap: per-service GreenOps attribution is \
undercounting, aggregate or reduce service names upstream"
),
));
}
let evicted = metrics.correlator_pairs_evicted_total.get();
if daemon.correlation.enabled && evicted > 0 {
let cap = daemon.correlation.max_tracked_pairs;
details.push(crate::report::Warning::new(
TUNING,
format!(
"{evicted} service pairs dropped at the correlation cap: \
raise [daemon.correlation] max_tracked_pairs (currently \
{cap}) or disable correlation on wide topologies"
),
));
}
let received = metrics.otlp_spans_received_total.get();
if received >= TUNING_ZERO_RETENTION_MIN_RECEIVED {
let filtered = instrumentation_gap_filtered(metrics);
if filtered >= received {
details.push(crate::report::Warning::new(
TUNING,
format!(
"all {received} received OTLP spans were filtered as \
non-analyzable (no db.statement, no http.url): the \
daemon will never produce findings, check the \
instrumentation exports I/O attributes or point \
instrumented services at this endpoint"
),
));
}
}
details
}
#[cfg(test)]
mod tests;