use serde::{Deserialize, Serialize};
use ucm_graph_core::entity::*;
use ucm_graph_core::event::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiLogEntry {
pub method: String,
pub path: String,
pub status_code: u16,
pub response_time_ms: u64,
pub handler: Option<String>,
pub timestamp: String,
}
pub fn ingest_api_logs(logs: &[ApiLogEntry]) -> Vec<UcmEvent> {
let mut events = Vec::new();
let mut groups: std::collections::HashMap<String, Vec<&ApiLogEntry>> =
std::collections::HashMap::new();
for log in logs {
let key = format!("{}:{}", log.method, log.path);
groups.entry(key).or_default().push(log);
}
for (key, entries) in &groups {
let first = entries[0];
let call_count = entries.len();
let avg_response =
entries.iter().map(|e| e.response_time_ms).sum::<u64>() / call_count as u64;
let error_rate =
entries.iter().filter(|e| e.status_code >= 400).count() as f64 / call_count as f64;
let confidence = (call_count as f64 / 100.0).clamp(0.3, 0.95);
events.push(UcmEvent::new(EventPayload::EntityDiscovered {
entity_id: EntityId::local(&format!("api/{}", first.path), key),
kind: EntityKind::ApiEndpoint {
method: first.method.clone(),
route: first.path.clone(),
handler: first.handler.clone().unwrap_or_else(|| "unknown".into()),
},
name: format!(
"{} {} ({} calls, {}ms avg)",
first.method, first.path, call_count, avg_response
),
file_path: format!("api/{}", first.path),
language: "api".to_string(),
source: DiscoverySource::ApiTraffic,
line_range: None,
}));
if error_rate > 0.05 {
events.push(UcmEvent::new(EventPayload::ConflictFlagged {
entity_id: EntityId::local(&format!("api/{}", first.path), key),
conflict_type: ucm_graph_core::event::ConflictType::RequirementDrift,
sources: vec![ucm_graph_core::event::ConflictSource {
source_type: "api-logs".into(),
claimed_value: format!("{:.1}% error rate", error_rate * 100.0),
confidence,
}],
description: format!(
"Endpoint {} {} has {:.1}% error rate over {} calls",
first.method,
first.path,
error_rate * 100.0,
call_count
),
}));
}
}
events
}
pub fn ingest_api_logs_json(json: &str) -> Result<Vec<UcmEvent>, serde_json::Error> {
let logs: Vec<ApiLogEntry> = serde_json::from_str(json)?;
Ok(ingest_api_logs(&logs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ingest_api_logs() {
let logs = vec![
ApiLogEntry {
method: "POST".into(),
path: "/api/v1/auth/login".into(),
status_code: 200,
response_time_ms: 150,
handler: Some("handleLogin".into()),
timestamp: "2024-01-15T10:00:00Z".into(),
},
ApiLogEntry {
method: "POST".into(),
path: "/api/v1/auth/login".into(),
status_code: 200,
response_time_ms: 120,
handler: Some("handleLogin".into()),
timestamp: "2024-01-15T10:01:00Z".into(),
},
];
let events = ingest_api_logs(&logs);
assert!(!events.is_empty());
let endpoints: Vec<_> = events
.iter()
.filter(|e| {
matches!(
&e.payload,
EventPayload::EntityDiscovered {
kind: EntityKind::ApiEndpoint { .. },
..
}
)
})
.collect();
assert_eq!(endpoints.len(), 1);
}
}