Menu
Public repository / 228b5716ba0b7bcc87cce261ea6df891133a35bb
AkurAI-CRM / crates/crm-api/src/handlers/client_logs.rs
Latest change b347e5072854c171398c23ae7bfcd3dce6647f9e - fmt: rustfmt after request-logging change by agent
use crate::router_setup::{bad_request, internal_error, json_response};
use akurai_http::{Request, Response};
use akurai_json::Value;
use reqwest::blocking::Client;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const APP: &str = "akurai-crm";
const HOST_ID: i64 = 1;
const APPLICATION_ID: i64 = 8;
const MAX_LOGS: usize = 500;
const MAX_LINE_CHARS: usize = 16_384;
pub fn client_logs_route() -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
let client = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client configuration is valid");
Box::new(move |req: &Request| {
let body = match std::str::from_utf8(&req.body)
.map_err(|e| format!("body not UTF-8: {e}"))
.and_then(|body| akurai_json::parse(body).map_err(|e| format!("invalid JSON: {e}")))
.and_then(normalize_logs)
{
Ok(body) => body,
Err(error) => return bad_request(&error),
};
let config = match monitor_config() {
Some(config) => config,
None => return internal_error("monitor ingest is not configured"),
};
match client
.post(&config.0)
.bearer_auth(&config.1)
.header("Content-Type", "application/json")
.body(body.to_json())
.send()
{
Ok(response) if response.status().is_success() => json_response(Value::Object(vec![(
"inserted".into(),
Value::Int(body.get("logs").map_or(0, |logs| match logs {
Value::Array(logs) => logs.len() as i64,
_ => 0,
})),
)])),
Ok(_) => internal_error("monitor ingest rejected client logs"),
Err(_) => internal_error("monitor ingest unavailable"),
}
})
}
fn monitor_config() -> Option<(String, String)> {
let url = std::env::var("MONITOR_INGEST_URL").ok()?;
let token = std::env::var("MONITOR_INGEST_TOKEN").ok()?;
if url.is_empty() || token.is_empty() {
return None;
}
let url = url.trim_end_matches('/');
let url = if url.ends_with("/api/ingest") {
url.to_owned()
} else {
format!("{url}/api/ingest")
};
Some((url, token))
}
fn normalize_logs(body: Value) -> Result<Value, String> {
let logs = match body.get("logs") {
Some(Value::Array(logs)) => logs,
_ => return Err("expected { logs: [...] }".into()),
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let logs = logs
.iter()
.take(MAX_LOGS)
.map(|log| {
let line = log
.get("line")
.map(string_value)
.unwrap_or_default()
.chars()
.take(MAX_LINE_CHARS)
.collect();
let level = match log.get("level").and_then(Value::as_str) {
Some("trace" | "debug" | "info" | "warn" | "error") => {
log.get("level").cloned().unwrap()
}
_ => Value::Str("info".into()),
};
let target = match log.get("target") {
Some(Value::Str(target)) => Value::Str(target.clone()),
_ => Value::Null,
};
let fields = log.get("fields").cloned().unwrap_or(Value::Null);
let ts = log.get("ts").and_then(Value::as_i64).unwrap_or(now);
Value::Object(vec![
("source".into(), Value::Str(format!("{APP}-client"))),
("line".into(), Value::Str(line)),
("level".into(), level),
("target".into(), target),
("origin".into(), Value::Str("client".into())),
("fields".into(), fields),
("host_id".into(), Value::Int(HOST_ID)),
("application_id".into(), Value::Int(APPLICATION_ID)),
("ts".into(), Value::Int(ts)),
])
})
.collect();
Ok(Value::Object(vec![("logs".into(), Value::Array(logs))]))
}
fn string_value(value: &Value) -> String {
match value {
Value::Str(value) => value.clone(),
value => value.to_json(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_metadata_cannot_override_proxy_attribution() {
let body = akurai_json::parse(
r#"{"logs":[{"source":"spoofed","line":"broken","level":"error","origin":"server","host_id":99,"application_id":99,"target":"/people","fields":{"detail":"x"},"ts":123}]}"#,
)
.unwrap();
let normalized = normalize_logs(body).unwrap();
let Value::Array(logs) = normalized.get("logs").unwrap() else {
panic!("logs should be an array");
};
let log = &logs[0];
assert_eq!(
log.get("source").and_then(Value::as_str),
Some("akurai-crm-client")
);
assert_eq!(log.get("origin").and_then(Value::as_str), Some("client"));
assert_eq!(log.get("host_id").and_then(Value::as_i64), Some(HOST_ID));
assert_eq!(
log.get("application_id").and_then(Value::as_i64),
Some(APPLICATION_ID)
);
assert_eq!(log.get("line").and_then(Value::as_str), Some("broken"));
}
#[test]
fn proxy_forwards_browser_logs_with_server_attribution() {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let monitor = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 4096];
let size = stream.read(&mut request).unwrap();
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 14\r\n\r\n{\"inserted\":1}")
.unwrap();
String::from_utf8_lossy(&request[..size]).into_owned()
});
std::env::set_var("MONITOR_INGEST_URL", endpoint);
std::env::set_var("MONITOR_INGEST_TOKEN", "test-token");
let mut request =
Request::parse_head(b"POST /api/client-logs HTTP/1.1\r\nContent-Length: 45\r\n\r\n")
.unwrap();
request.body = br#"{"logs":[{"line":"browser failure","level":"error"}]}"#.to_vec();
let response = client_logs_route()(&request);
std::env::remove_var("MONITOR_INGEST_URL");
std::env::remove_var("MONITOR_INGEST_TOKEN");
assert_eq!(response.status, 200);
let forwarded = monitor.join().unwrap();
assert!(forwarded.starts_with("POST /api/ingest HTTP/1.1"));
assert!(forwarded.contains("authorization: Bearer test-token"));
assert!(forwarded.contains(r#""host_id":1"#));
assert!(forwarded.contains(r#""application_id":8"#));
assert!(forwarded.contains(r#""origin":"client""#));
}
}