mod langfuse;
mod types;
pub use langfuse::{GraphLangfuseExporter, SpanMetadataFn};
pub use types::*;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::SystemTime;
use async_trait::async_trait;
use crate::error::Result;
use crate::graph::status::GraphRunStatus;
use crate::graph::stream::{GraphEvent, GraphEventSink};
use crate::harness::ids::{CheckpointId, EventId, GraphId, NodeId, RunId, ThreadId, now_ms};
use crate::harness::observability::{AppendWorker, DEFAULT_DRAIN_CAPACITY};
use crate::harness::store::AppendStore;
impl GraphLatencyMetrics {
pub fn from_observations(observations: &[GraphObservation]) -> Self {
let mut metrics = Self::default();
let mut run_start: Option<u64> = None;
let mut step_starts: HashMap<usize, u64> = HashMap::new();
let mut node_starts: HashMap<(NodeId, usize), VecDeque<u64>> = HashMap::new();
for obs in observations {
match &obs.event {
GraphEvent::RunStarted { .. } if run_start.is_none() => {
run_start = Some(obs.ts_ms);
}
GraphEvent::RunStarted { .. } => {}
GraphEvent::RunCompleted { .. } | GraphEvent::RunFailed { .. } => {
if metrics.run_elapsed_ms.is_none()
&& let Some(start) = run_start
{
metrics.run_elapsed_ms = Some(obs.ts_ms.saturating_sub(start));
}
}
GraphEvent::StepStarted { step, .. } => {
step_starts.insert(*step, obs.ts_ms);
}
GraphEvent::StepCompleted { step } => {
if let Some(start) = step_starts.remove(step) {
metrics.record_step(GraphStepLatency {
step: *step,
elapsed_ms: obs.ts_ms.saturating_sub(start),
});
}
}
GraphEvent::NodeStarted { node, step } => {
node_starts
.entry((node.clone(), *step))
.or_default()
.push_back(obs.ts_ms);
}
GraphEvent::NodeCompleted { node, step } => {
if let Some(start) = pop_node_start(&mut node_starts, node, *step) {
metrics.record_node(GraphNodeLatency {
node: node.clone(),
step: *step,
elapsed_ms: obs.ts_ms.saturating_sub(start),
failed: false,
});
}
}
GraphEvent::NodeFailed { node, step, .. } => {
if let Some(start) = pop_node_start(&mut node_starts, node, *step) {
metrics.record_node(GraphNodeLatency {
node: node.clone(),
step: *step,
elapsed_ms: obs.ts_ms.saturating_sub(start),
failed: true,
});
}
}
_ => {}
}
}
metrics
}
pub fn from_status(status: &GraphRunStatus) -> Self {
let end = status.ended_at.unwrap_or(status.updated_at);
Self {
run_elapsed_ms: duration_ms(status.started_at, end),
..Self::default()
}
}
pub fn average_step_ms(&self) -> Option<u64> {
average(self.total_step_ms, self.steps.len())
}
pub fn average_node_ms(&self) -> Option<u64> {
average(self.total_node_ms, self.nodes.len())
}
fn record_step(&mut self, latency: GraphStepLatency) {
self.total_step_ms = self.total_step_ms.saturating_add(latency.elapsed_ms);
self.max_step_ms = self.max_step_ms.max(latency.elapsed_ms);
self.steps.push(latency);
}
fn record_node(&mut self, latency: GraphNodeLatency) {
self.total_node_ms = self.total_node_ms.saturating_add(latency.elapsed_ms);
self.max_node_ms = self.max_node_ms.max(latency.elapsed_ms);
self.nodes.push(latency);
}
}
impl GraphHealthSummary {
pub fn from_observations(observations: &[GraphObservation]) -> Self {
let mut per_node: HashMap<NodeId, GraphNodeHealth> = HashMap::new();
let mut summary = Self::default();
for obs in observations {
match &obs.event {
GraphEvent::NodeStarted { node, .. } => {
entry_for(&mut per_node, node).started += 1;
summary.total_started += 1;
}
GraphEvent::NodeCompleted { node, .. } => {
entry_for(&mut per_node, node).completed += 1;
summary.total_completed += 1;
}
GraphEvent::NodeFailed { node, .. } => {
entry_for(&mut per_node, node).failed += 1;
summary.total_failed += 1;
}
GraphEvent::RunFailed { .. } => {
summary.run_failed = true;
}
_ => {}
}
}
summary.nodes = per_node.into_values().collect();
summary
.nodes
.sort_by(|a, b| a.node.as_str().cmp(b.node.as_str()));
summary
}
}
fn entry_for<'a>(
per_node: &'a mut HashMap<NodeId, GraphNodeHealth>,
node: &NodeId,
) -> &'a mut GraphNodeHealth {
per_node
.entry(node.clone())
.or_insert_with(|| GraphNodeHealth {
node: node.clone(),
started: 0,
completed: 0,
failed: 0,
})
}
impl InMemoryGraphEventJournal {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self, run_id: &str) -> usize {
self.runs
.lock()
.expect("InMemoryGraphEventJournal lock poisoned")
.get(run_id)
.map(|v| v.len())
.unwrap_or(0)
}
pub fn is_empty(&self, run_id: &str) -> bool {
self.len(run_id) == 0
}
}
#[async_trait]
impl GraphEventJournal for InMemoryGraphEventJournal {
async fn append(&self, obs: GraphObservation) -> Result<u64> {
let mut runs = self
.runs
.lock()
.map_err(|e| poisoned("InMemoryGraphEventJournal", e))?;
let entries = runs.entry(obs.run_id.as_str().to_string()).or_default();
let offset = entries.len() as u64;
entries.push(obs);
Ok(offset)
}
async fn read_from(&self, run_id: &str, offset: u64) -> Result<Vec<GraphObservation>> {
let runs = self
.runs
.lock()
.map_err(|e| poisoned("InMemoryGraphEventJournal", e))?;
let Some(entries) = runs.get(run_id) else {
return Ok(Vec::new());
};
Ok(entries.iter().skip(offset as usize).cloned().collect())
}
}
impl<A: AppendStore> StoreGraphEventJournal<A> {
pub fn new(store: A) -> Self {
Self { store }
}
pub fn store(&self) -> &A {
&self.store
}
}
#[async_trait]
impl<A: AppendStore + 'static> GraphEventJournal for StoreGraphEventJournal<A> {
async fn append(&self, obs: GraphObservation) -> Result<u64> {
let stream = obs.run_id.as_str().to_string();
let value = serde_json::to_value(&obs)?;
self.store.append(&stream, value).await
}
async fn read_from(&self, run_id: &str, offset: u64) -> Result<Vec<GraphObservation>> {
let raw = self.store.read_from(run_id, offset).await?;
let mut out = Vec::with_capacity(raw.len());
for (_offset, value) in raw {
out.push(serde_json::from_value(value)?);
}
Ok(out)
}
}
impl InMemoryGraphStatusStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_runs(mut self, max: usize) -> Self {
self.max_runs = Some(max);
self
}
pub fn len(&self) -> usize {
self.state
.lock()
.expect("InMemoryGraphStatusStore lock poisoned")
.statuses
.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl StatusStoreState {
fn unindex_thread(&mut self, thread_id: &str, run_id: &str) {
if let Some(runs) = self.by_thread.get_mut(thread_id) {
runs.retain(|id| id != run_id);
if runs.is_empty() {
self.by_thread.remove(thread_id);
}
}
}
fn evict_one(&mut self) {
let idx = self
.order
.iter()
.position(|id| self.statuses.get(id).is_none_or(|s| s.is_terminal()))
.unwrap_or(0);
let Some(run_id) = self.order.remove(idx) else {
return;
};
if let Some(evicted) = self.statuses.remove(&run_id)
&& let Some(thread_id) = evicted.thread_id.as_ref()
{
self.unindex_thread(thread_id.as_str(), &run_id);
}
}
}
#[async_trait]
impl GraphStatusStore for InMemoryGraphStatusStore {
async fn put_status(&self, status: GraphRunStatus) -> Result<()> {
let mut state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryGraphStatusStore", e))?;
let run_id = status.run_id.as_str().to_string();
let thread_id = status.thread_id.as_ref().map(|t| t.as_str().to_string());
match state.statuses.insert(run_id.clone(), status) {
Some(previous) => {
let previous_thread = previous.thread_id.as_ref().map(|t| t.as_str().to_string());
if previous_thread != thread_id {
if let Some(old) = previous_thread {
state.unindex_thread(&old, &run_id);
}
if let Some(new) = thread_id {
state.by_thread.entry(new).or_default().push(run_id);
}
}
}
None => {
if let Some(thread) = thread_id {
state
.by_thread
.entry(thread)
.or_default()
.push(run_id.clone());
}
state.order.push_back(run_id);
if let Some(max) = self.max_runs {
while state.statuses.len() > max {
state.evict_one();
}
}
}
}
Ok(())
}
async fn get_status(&self, run_id: &str) -> Result<Option<GraphRunStatus>> {
let state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryGraphStatusStore", e))?;
Ok(state.statuses.get(run_id).cloned())
}
async fn list_by_thread(&self, thread_id: &str) -> Result<Vec<GraphRunStatus>> {
let state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryGraphStatusStore", e))?;
Ok(state
.by_thread
.get(thread_id)
.map(|runs| {
runs.iter()
.filter_map(|id| state.statuses.get(id).cloned())
.collect()
})
.unwrap_or_default())
}
}
impl JournalGraphSink {
pub fn new(journal: Arc<dyn GraphEventJournal>, run_id: RunId, graph_id: GraphId) -> Self {
let worker = Arc::new(AppendWorker::spawn(
"graph-journal-sink",
DEFAULT_DRAIN_CAPACITY,
move |obs: GraphObservation| {
let journal = Arc::clone(&journal);
async move { journal.append(obs).await.map(|_| ()) }
},
));
Self {
worker,
inner: None,
root_run_id: run_id.clone(),
run_id,
parent_run_id: None,
thread_id: None,
graph_id,
namespace: Vec::new(),
offset: Arc::new(std::sync::atomic::AtomicU64::new(0)),
step: Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
pub fn with_lineage(mut self, parent_run_id: Option<RunId>, root_run_id: RunId) -> Self {
self.parent_run_id = parent_run_id;
self.root_run_id = root_run_id;
self
}
pub fn with_thread(mut self, thread_id: Option<ThreadId>) -> Self {
self.thread_id = thread_id;
self
}
pub fn with_namespace(mut self, namespace: Vec<String>) -> Self {
self.namespace = namespace;
self
}
pub fn with_inner(mut self, inner: Arc<dyn GraphEventSink>) -> Self {
self.inner = Some(inner);
self
}
fn observe(&self, event: &GraphEvent) -> GraphObservation {
let offset = self.offset.fetch_add(1, Ordering::Relaxed);
let step = match event.step() {
Some(step) => {
self.step.store(step as u64, Ordering::Relaxed);
step
}
None => self.step.load(Ordering::Relaxed) as usize,
};
GraphObservation {
event_id: EventId::new(format!("{}-{offset}", self.run_id.as_str())),
run_id: self.run_id.clone(),
root_run_id: self.root_run_id.clone(),
parent_run_id: self.parent_run_id.clone(),
thread_id: self.thread_id.clone(),
graph_id: self.graph_id.clone(),
checkpoint_id: checkpoint_of(event),
namespace: self.namespace.clone(),
step,
offset,
ts_ms: now_ms(),
event: event.clone(),
}
}
}
impl GraphEventSink for JournalGraphSink {
fn emit(&self, event: GraphEvent) {
let obs = self.observe(&event);
self.worker.submit(obs);
if let Some(inner) = &self.inner {
inner.emit(event);
}
}
fn flush(&self) {
self.worker.flush();
if let Some(inner) = &self.inner {
inner.flush();
}
}
}
fn checkpoint_of(event: &GraphEvent) -> Option<CheckpointId> {
match event {
GraphEvent::CheckpointSaved { checkpoint_id } => Some(checkpoint_id.clone()),
_ => None,
}
}
fn pop_node_start(
starts: &mut HashMap<(NodeId, usize), VecDeque<u64>>,
node: &NodeId,
step: usize,
) -> Option<u64> {
let key = (node.clone(), step);
let queue = starts.get_mut(&key)?;
let start = queue.pop_front();
if queue.is_empty() {
starts.remove(&key);
}
start
}
fn average(total: u64, count: usize) -> Option<u64> {
(count > 0).then_some(total / count as u64)
}
fn duration_ms(start: SystemTime, end: SystemTime) -> Option<u64> {
end.duration_since(start)
.ok()
.map(|duration| duration.as_millis() as u64)
}
fn poisoned<E: std::fmt::Display>(what: &str, err: E) -> crate::error::TinyAgentsError {
crate::error::TinyAgentsError::Validation(format!("{what} lock poisoned: {err}"))
}
#[cfg(test)]
mod test;