use std::path::{Path, PathBuf};
use serde::Serialize;
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TransportKind {
Fast,
A2a,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Inbound,
Outbound,
}
#[derive(Debug, Serialize)]
pub struct LogEntry {
pub ts: String,
pub transport: TransportKind,
pub direction: Direction,
pub task_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub duration_us: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub llm_us: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transport_us: Option<u64>,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload_bytes: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct TransportLogger {
tx: mpsc::Sender<LogEntry>,
}
impl TransportLogger {
pub fn builder() -> TransportLoggerBuilder {
TransportLoggerBuilder::default()
}
#[inline]
pub fn record(&self, entry: LogEntry) {
let _ = self.tx.try_send(entry);
}
}
pub struct TransportLoggerBuilder {
log_dir: Option<PathBuf>,
max_entries: usize,
channel_capacity: usize,
}
impl Default for TransportLoggerBuilder {
fn default() -> Self {
Self {
log_dir: None,
max_entries: 1_000,
channel_capacity: 4_096,
}
}
}
impl TransportLoggerBuilder {
#[must_use]
pub fn log_dir(mut self, path: impl AsRef<Path>) -> Self {
self.log_dir = Some(path.as_ref().to_path_buf());
self
}
#[must_use]
pub fn max_entries(mut self, n: usize) -> Self {
self.max_entries = n.max(1);
self
}
#[must_use]
pub fn channel_capacity(mut self, n: usize) -> Self {
self.channel_capacity = n.max(1);
self
}
pub async fn build(self) -> Result<TransportLogger, std::io::Error> {
let log_dir = self.log_dir.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "log_dir not set")
})?;
tokio::fs::create_dir_all(&log_dir).await?;
let (tx, rx) = mpsc::channel(self.channel_capacity);
tokio::spawn(writer_task(rx, log_dir, self.max_entries));
Ok(TransportLogger { tx })
}
}
async fn writer_task(mut rx: mpsc::Receiver<LogEntry>, log_dir: PathBuf, max_entries: usize) {
let mut count: usize = 0;
let mut file: Option<tokio::fs::File> = None;
while let Some(entry) = rx.recv().await {
if file.is_none() || count >= max_entries {
match open_log_file(&log_dir).await {
Ok(f) => {
file = Some(f);
count = 0;
}
Err(e) => {
tracing::warn!(error = %e, "transport-log: failed to open log file, dropping entry");
continue;
}
}
}
if let Some(ref mut f) = file {
match serde_json::to_string(&entry) {
Ok(mut line) => {
line.push('\n');
if let Err(e) = f.write_all(line.as_bytes()).await {
tracing::warn!(error = %e, "transport-log: write failed, will reopen on next entry");
file = None; } else {
count += 1;
}
}
Err(e) => {
tracing::warn!(error = %e, "transport-log: entry serialization failed");
}
}
}
}
if let Some(mut f) = file {
let _ = f.flush().await;
}
}
async fn open_log_file(log_dir: &Path) -> Result<tokio::fs::File, std::io::Error> {
let path = log_dir.join(format!("transport-{}.ndjson", now_filename_safe()));
tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await
}
pub fn now_iso8601() -> String {
let (y, mo, d, h, mi, s, us) = now_parts();
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{us:06}Z")
}
fn now_filename_safe() -> String {
let (y, mo, d, h, mi, s, us) = now_parts();
format!("{y:04}-{mo:02}-{d:02}T{h:02}-{mi:02}-{s:02}.{us:06}Z")
}
fn now_parts() -> (u64, u64, u64, u64, u64, u64, u32) {
use std::time::{SystemTime, UNIX_EPOCH};
let dur = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let total_secs = dur.as_secs();
let us = dur.subsec_micros();
let time_secs = total_secs % 86_400;
let h = time_secs / 3_600;
let mi = (time_secs % 3_600) / 60;
let s = time_secs % 60;
let (y, mo, d) = days_to_ymd(total_secs / 86_400);
(y, mo, d, h, mi, s, us)
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let mo = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if mo <= 2 { y + 1 } else { y };
(year, mo, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso8601_unix_epoch() {
assert_eq!(days_to_ymd(0), (1970, 1, 1));
}
#[test]
fn iso8601_known_date() {
assert_eq!(days_to_ymd(20_612), (2026, 6, 8));
}
#[test]
fn iso8601_leap_day() {
assert_eq!(days_to_ymd(11_016), (2000, 2, 29));
}
#[test]
fn log_entry_serializes_without_optional_fields() {
let entry = LogEntry {
ts: "2026-06-08T09:51:00.000000Z".into(),
transport: TransportKind::Fast,
direction: Direction::Outbound,
task_id: "abc".into(),
session_id: None,
duration_us: 100,
llm_us: None,
transport_us: None,
status: "ok",
error: None,
payload_bytes: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("error"));
assert!(!json.contains("payload_bytes"));
assert!(json.contains("\"fast\""));
assert!(json.contains("\"outbound\""));
}
}