use crate::service::Scryer;
use observation::{Event, EventScope, EventSource, IngestLine, Level, TaskRunId};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixListener;
use workload_spec::MeshIdent;
#[derive(Debug, Error)]
pub enum IngestionError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("scryer push: {0}")]
Push(#[from] crate::service::ScryerError),
}
#[derive(Default)]
struct SeqCounters(HashMap<String, u32>);
impl SeqCounters {
fn next(&mut self, scope_key: &str) -> u32 {
let c = self.0.entry(scope_key.to_string()).or_insert(0);
let v = *c;
*c = c.wrapping_add(1);
v
}
}
pub struct IngestionServer {
scryer: Arc<Scryer>,
socket_path: PathBuf,
started_at: Instant,
seq_counters: Arc<Mutex<SeqCounters>>,
}
impl IngestionServer {
pub fn new(scryer: Arc<Scryer>, socket_path: impl AsRef<Path>) -> Self {
Self {
scryer,
socket_path: socket_path.as_ref().to_owned(),
started_at: Instant::now(),
seq_counters: Arc::new(Mutex::new(SeqCounters::default())),
}
}
pub async fn bind(&self) -> Result<BoundIngestion, IngestionError> {
if let Some(parent) = self.socket_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
if tokio::fs::metadata(&self.socket_path).await.is_ok() {
tokio::fs::remove_file(&self.socket_path).await?;
}
let listener = UnixListener::bind(&self.socket_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o666))?;
}
Ok(BoundIngestion {
listener,
scryer: Arc::clone(&self.scryer),
started_at: self.started_at,
seq_counters: Arc::clone(&self.seq_counters),
})
}
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
}
pub struct BoundIngestion {
listener: UnixListener,
scryer: Arc<Scryer>,
started_at: Instant,
seq_counters: Arc<Mutex<SeqCounters>>,
}
impl BoundIngestion {
pub async fn serve(self) -> Result<(), IngestionError> {
let listener = self.listener;
loop {
let (stream, _) = listener.accept().await?;
let scryer = Arc::clone(&self.scryer);
let started_at = self.started_at;
let seq_counters = Arc::clone(&self.seq_counters);
tokio::spawn(async move {
let reader = BufReader::new(stream);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(parsed) = serde_json::from_str::<IngestLine>(&line) else {
continue;
};
let (scope_kind, scope_id) = parsed.scope();
if scope_kind != "service" {
continue;
}
let scope = EventScope::Service(MeshIdent(scope_id.to_string()));
let scope_key = format!("service:{scope_id}");
match parsed {
IngestLine::Span { span, .. } => {
let _ = scryer.store().insert_spans(&[(scope, *span)]);
}
IngestLine::Event { level, target, msg, fields, .. } => {
let seq = {
let mut c = seq_counters.lock().unwrap();
c.next(&scope_key)
};
let offset_ms =
started_at.elapsed().as_millis().min(u32::MAX as u128) as u32;
let level = Level::from_str(&level).unwrap_or(Level::Info);
let event = Event {
run_id: TaskRunId::new(),
seq,
offset_ms,
level,
target,
msg,
fields,
anchor: None,
source: EventSource::Shim {
lib: "yah-log".to_string(),
version: "unknown".to_string(),
},
};
let _ = scryer.push(scope, event);
}
}
}
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::{EventFilter, Scryer, ScryerConfig};
use observation::Level as ObsLevel;
use serde_json::json;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
fn open_scryer(dir: &TempDir) -> Arc<Scryer> {
let cfg = ScryerConfig::new(dir.path().join("events.db"));
Arc::new(Scryer::new(cfg, None).unwrap())
}
#[tokio::test]
async fn a_stale_socket_file_does_not_wedge_the_next_bind() {
let dir = TempDir::new().unwrap();
let socket_path = dir.path().join("nested").join("stale.sock");
let scryer = open_scryer(&dir);
let first = IngestionServer::new(Arc::clone(&scryer), &socket_path)
.bind()
.await
.expect("first bind creates its parent dir");
drop(first);
assert!(socket_path.exists(), "the socket inode outlives the listener");
IngestionServer::new(Arc::clone(&scryer), &socket_path)
.bind()
.await
.expect("a stale socket file must be cleared, not fatal");
}
#[tokio::test]
async fn ingestion_server_service_scope() {
let dir = TempDir::new().unwrap();
let socket_path = dir.path().join("ingest.sock");
let scryer = open_scryer(&dir);
let bound = IngestionServer::new(Arc::clone(&scryer), &socket_path)
.bind()
.await
.unwrap();
let server_task = tokio::spawn(async move { let _ = bound.serve().await; });
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
let line = format!(
"{}\n",
json!({
"signal": "event",
"scope_kind": "service",
"scope_id": "my-service.host",
"level": "info",
"target": "ingestion::test",
"msg": "hello from yah-log",
"fields": { "service_ident": "my-service.host" },
"_lib": "yah-log",
"_lib_ver": "0.1.0"
})
);
stream.write_all(line.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
drop(stream);
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
server_task.abort();
scryer.flush_ring().unwrap();
let scope = EventScope::Service(MeshIdent("my-service.host".to_string()));
let events = scryer.events(&scope, &EventFilter::default()).await.unwrap();
assert_eq!(events.len(), 1, "expected 1 service-scope event; got {:?}", events.len());
assert_eq!(events[0].level, ObsLevel::Info);
assert_eq!(events[0].target, "ingestion::test");
assert_eq!(events[0].msg, "hello from yah-log");
}
#[tokio::test]
async fn ingestion_server_skips_unknown_scope() {
let dir = TempDir::new().unwrap();
let socket_path = dir.path().join("unknown_scope.sock");
let scryer = open_scryer(&dir);
let bound = IngestionServer::new(Arc::clone(&scryer), &socket_path)
.bind()
.await
.unwrap();
let server_task = tokio::spawn(async move { let _ = bound.serve().await; });
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
let lines = format!(
"{}\n{}\n",
json!({"signal":"event","scope_kind":"future","scope_id":"x","level":"info","target":"t","msg":"m","fields":{}}),
json!({"signal":"event","scope_kind":"service","scope_id":"svc.host","level":"warn","target":"t","msg":"kept","fields":{}})
);
stream.write_all(lines.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
drop(stream);
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
server_task.abort();
scryer.flush_ring().unwrap();
let scope = EventScope::Service(MeshIdent("svc.host".to_string()));
let events = scryer.events(&scope, &EventFilter::default()).await.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].level, ObsLevel::Warn);
}
#[tokio::test]
async fn ingestion_server_seq_monotonic_across_connections() {
let dir = TempDir::new().unwrap();
let socket_path = dir.path().join("seq_mono.sock");
let scryer = open_scryer(&dir);
let bound = IngestionServer::new(Arc::clone(&scryer), &socket_path)
.bind()
.await
.unwrap();
let server_task = tokio::spawn(async move { let _ = bound.serve().await; });
let write_event = |msg: &'static str| {
let socket_path = socket_path.clone();
async move {
let mut s = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
let line = format!(
"{}\n",
json!({"signal":"event","scope_kind":"service","scope_id":"seq.host","level":"info","target":"t","msg":msg,"fields":{}})
);
s.write_all(line.as_bytes()).await.unwrap();
s.flush().await.unwrap();
drop(s);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
};
write_event("event-0").await;
write_event("event-1").await;
server_task.abort();
scryer.flush_ring().unwrap();
let scope = EventScope::Service(MeshIdent("seq.host".to_string()));
let events = scryer.events(&scope, &EventFilter::default()).await.unwrap();
assert_eq!(events.len(), 2);
assert!(events[0].seq < events[1].seq, "seq must be monotonically increasing");
}
#[tokio::test]
async fn ingestion_server_accepts_spans_and_they_do_not_land_as_events() {
use observation::{IngestLine, Span, SpanId, SpanKind, SpanStatus, TraceId};
let dir = TempDir::new().unwrap();
let socket_path = dir.path().join("spans.sock");
let scryer = open_scryer(&dir);
let bound = IngestionServer::new(Arc::clone(&scryer), &socket_path)
.bind()
.await
.unwrap();
let server_task = tokio::spawn(async move { let _ = bound.serve().await; });
let span = Span {
trace_id: TraceId([7u8; 16]),
span_id: SpanId([9u8; 8]),
parent_span_id: None,
name: "GET /orders".to_string(),
kind: SpanKind::Server,
start_unix_nanos: 1_700_000_000_000_000_000,
duration_nanos: 4_000_000,
status: SpanStatus::Ok,
attributes: Default::default(),
};
let line = format!(
"{}\n",
serde_json::to_string(&IngestLine::span("span.host", span)).unwrap()
);
let mut stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
stream.write_all(line.as_bytes()).await.unwrap();
stream.flush().await.unwrap();
drop(stream);
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
server_task.abort();
scryer.flush_ring().unwrap();
assert_eq!(scryer.store().count_spans().unwrap(), 1, "span must reach the spans table");
let scope = EventScope::Service(MeshIdent("span.host".to_string()));
let events = scryer.events(&scope, &EventFilter::default()).await.unwrap();
assert!(events.is_empty(), "a span must not be recorded as a log event");
}
}