Skip to content

Commit 60e9701

Browse files
committed
feat(observability): stamp langgraph Agent-Graph-view keys + host span-metadata injector on graph Langfuse exporter
- node spans now carry langgraph_node (node id) and langgraph_step (superstep index) in metadata so Langfuse's Agent Graph view can lay the trace out as a graph; step spans carry langgraph_step; subgraph spans carry langgraph_step from their coordinate observation - GraphLangfuseExporter::with_span_metadata_fn installs an optional host metadata injector (Fn(&GraphObservation) -> Option<Map>) merged over built-in span metadata keys (host wins on collision); default None, non-breaking - re-export SpanMetadataFn from graph/observability, graph, and crate root Claude-Session: https://claude.ai/code/session_01VSwRwLDBXg3o6timchvKn8
1 parent 74de1a6 commit 60e9701

5 files changed

Lines changed: 197 additions & 20 deletions

File tree

src/graph/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ pub use goals::{
6969
pub use observability::{
7070
GraphEventJournal, GraphHealthSummary, GraphLangfuseExporter, GraphLatencyMetrics,
7171
GraphNodeHealth, GraphNodeLatency, GraphObservation, GraphStatusStore, GraphStepLatency,
72-
InMemoryGraphEventJournal, InMemoryGraphStatusStore, JournalGraphSink, StoreGraphEventJournal,
72+
InMemoryGraphEventJournal, InMemoryGraphStatusStore, JournalGraphSink, SpanMetadataFn,
73+
StoreGraphEventJournal,
7374
};
7475
pub use orchestration::{
7576
InMemoryTaskStore, JsonlTaskStore, OrchestrationControlOutcome, OrchestrationTaskFilter,

src/graph/observability/langfuse.rs

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525
2626
use std::collections::HashMap;
2727
use std::collections::VecDeque;
28+
use std::sync::Arc;
2829

29-
use serde_json::{Value, json};
30+
use serde_json::{Map, Value, json};
3031

3132
use crate::error::{Result, TinyAgentsError};
3233
use crate::graph::observability::{GraphHealthSummary, GraphObservation};
@@ -39,20 +40,43 @@ use crate::harness::observability::{LangfuseClient, LangfuseTraceConfig, clean_n
3940
/// arrival order and pair with their terminals FIFO.
4041
type StartQueue = VecDeque<(usize, u64)>;
4142

43+
/// A host-supplied hook that contributes extra metadata to every span the
44+
/// exporter emits. Called with the span's coordinate observation; returning
45+
/// `None` (or an empty map) contributes nothing. Host keys are merged **over**
46+
/// the exporter's built-in coordinate keys, so a host can also override them.
47+
pub type SpanMetadataFn = dyn Fn(&GraphObservation) -> Option<Map<String, Value>> + Send + Sync;
48+
4249
/// Async Langfuse exporter for durable graph observations.
4350
///
4451
/// Wraps a shared [`LangfuseClient`] for transport (auth, endpoint
4552
/// normalization, `207 Multi-Status` handling) and adds graph-aware payload
4653
/// construction on top.
47-
#[derive(Clone, Debug)]
54+
#[derive(Clone)]
4855
pub struct GraphLangfuseExporter {
4956
client: LangfuseClient,
57+
/// Optional host metadata injector, merged into every span's metadata.
58+
span_metadata_fn: Option<Arc<SpanMetadataFn>>,
59+
}
60+
61+
impl std::fmt::Debug for GraphLangfuseExporter {
62+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63+
f.debug_struct("GraphLangfuseExporter")
64+
.field("client", &self.client)
65+
.field(
66+
"span_metadata_fn",
67+
&self.span_metadata_fn.as_ref().map(|_| "Fn(..)"),
68+
)
69+
.finish()
70+
}
5071
}
5172

5273
impl GraphLangfuseExporter {
5374
/// Wraps an existing [`LangfuseClient`] as a graph exporter.
5475
pub fn new(client: LangfuseClient) -> Self {
55-
Self { client }
76+
Self {
77+
client,
78+
span_metadata_fn: None,
79+
}
5680
}
5781

5882
/// Builds an exporter from the same environment variables as
@@ -61,6 +85,17 @@ impl GraphLangfuseExporter {
6185
Ok(Self::new(LangfuseClient::from_env()?))
6286
}
6387

88+
/// Installs a host metadata injector: `f` is called once per emitted span
89+
/// with the span's coordinate observation, and any returned keys are merged
90+
/// into that span's metadata (host keys win on collision). Default: none.
91+
pub fn with_span_metadata_fn(
92+
mut self,
93+
f: impl Fn(&GraphObservation) -> Option<Map<String, Value>> + Send + Sync + 'static,
94+
) -> Self {
95+
self.span_metadata_fn = Some(Arc::new(f));
96+
self
97+
}
98+
6499
/// Returns the underlying transport client.
65100
pub fn client(&self) -> &LangfuseClient {
66101
&self.client
@@ -97,7 +132,13 @@ impl GraphLangfuseExporter {
97132
batch.push(trace_create(&trace_id, &trace_ts, &trace, first, &health));
98133

99134
let mut consumed = vec![false; observations.len()];
100-
push_span_events(&trace_id, observations, &mut consumed, &mut batch);
135+
push_span_events(
136+
&trace_id,
137+
observations,
138+
&mut consumed,
139+
&mut batch,
140+
self.span_metadata_fn.as_deref(),
141+
);
101142
push_point_events(&trace_id, observations, &consumed, &mut batch);
102143

103144
Ok(json!({ "batch": batch }))
@@ -187,6 +228,7 @@ fn push_span_events(
187228
observations: &[GraphObservation],
188229
consumed: &mut [bool],
189230
batch: &mut Vec<Value>,
231+
injector: Option<&SpanMetadataFn>,
190232
) {
191233
// Pending starts keyed by their span identity, each holding (index, ts).
192234
let mut step_starts: HashMap<usize, StartQueue> = HashMap::new();
@@ -212,6 +254,7 @@ fn push_span_events(
212254
Some(obs.ts_ms),
213255
obs,
214256
&observations[start_idx],
257+
injector,
215258
));
216259
}
217260
}
@@ -233,6 +276,7 @@ fn push_span_events(
233276
Some(obs.ts_ms),
234277
None,
235278
obs,
279+
injector,
236280
));
237281
}
238282
}
@@ -247,6 +291,7 @@ fn push_span_events(
247291
Some(obs.ts_ms),
248292
Some(error.as_str()),
249293
obs,
294+
injector,
250295
));
251296
}
252297
}
@@ -269,6 +314,7 @@ fn push_span_events(
269314
start_ts,
270315
Some(obs.ts_ms),
271316
obs,
317+
injector,
272318
));
273319
}
274320
}
@@ -280,7 +326,9 @@ fn push_span_events(
280326
for (step, mut queue) in step_starts {
281327
while let Some((start_idx, start_ts)) = queue.pop_front() {
282328
let obs = &observations[start_idx];
283-
batch.push(step_span(trace_id, step, start_ts, None, obs, obs));
329+
batch.push(step_span(
330+
trace_id, step, start_ts, None, obs, obs, injector,
331+
));
284332
}
285333
}
286334
for ((node, step), mut queue) in node_starts {
@@ -293,6 +341,7 @@ fn push_span_events(
293341
None,
294342
None,
295343
&observations[start_idx],
344+
injector,
296345
));
297346
}
298347
}
@@ -305,6 +354,7 @@ fn push_span_events(
305354
start_ts,
306355
None,
307356
&observations[start_idx],
357+
injector,
308358
));
309359
}
310360
}
@@ -342,21 +392,24 @@ fn push_point_events(
342392
"startTime": ts,
343393
"level": level,
344394
"statusMessage": status,
345-
"metadata": span_metadata(obs),
395+
"metadata": span_metadata(obs, None, None, None),
346396
})),
347397
}));
348398
}
349399
}
350400

351401
/// Builds a `span-create` for a superstep, parented directly to the trace.
402+
#[allow(clippy::too_many_arguments)]
352403
fn step_span(
353404
trace_id: &str,
354405
step: usize,
355406
start_ts: u64,
356407
end_ts: Option<u64>,
357408
terminal: &GraphObservation,
358409
start: &GraphObservation,
410+
injector: Option<&SpanMetadataFn>,
359411
) -> Value {
412+
let metadata = span_metadata(start, None, Some(step), injector);
360413
span_event(
361414
trace_id,
362415
&format!("{trace_id}:step:{step}"),
@@ -366,11 +419,15 @@ fn step_span(
366419
end_ts,
367420
None,
368421
terminal,
369-
start,
422+
metadata,
370423
)
371424
}
372425

373426
/// Builds a `span-create` for a node handler, parented to its superstep span.
427+
///
428+
/// Node spans carry the Langfuse **Agent Graph view** keys `langgraph_node`
429+
/// (the node id) and `langgraph_step` (the superstep index) in metadata, so
430+
/// Langfuse can lay the trace out as a graph.
374431
#[allow(clippy::too_many_arguments)]
375432
fn node_span(
376433
trace_id: &str,
@@ -380,7 +437,9 @@ fn node_span(
380437
end_ts: Option<u64>,
381438
error: Option<&str>,
382439
terminal: &GraphObservation,
440+
injector: Option<&SpanMetadataFn>,
383441
) -> Value {
442+
let metadata = span_metadata(terminal, Some(node.as_str()), Some(step), injector);
384443
span_event(
385444
trace_id,
386445
&format!("{trace_id}:node:{}:{step}", node.as_str()),
@@ -390,7 +449,7 @@ fn node_span(
390449
end_ts,
391450
error,
392451
terminal,
393-
terminal,
452+
metadata,
394453
)
395454
}
396455

@@ -402,7 +461,9 @@ fn subgraph_span(
402461
start_ts: u64,
403462
end_ts: Option<u64>,
404463
terminal: &GraphObservation,
464+
injector: Option<&SpanMetadataFn>,
405465
) -> Value {
466+
let metadata = span_metadata(terminal, None, Some(terminal.step), injector);
406467
span_event(
407468
trace_id,
408469
&format!("{trace_id}:subgraph:{}", namespace.join("/")),
@@ -412,13 +473,13 @@ fn subgraph_span(
412473
end_ts,
413474
None,
414475
terminal,
415-
terminal,
476+
metadata,
416477
)
417478
}
418479

419480
/// Shared `span-create` builder. `error` promotes the span to `ERROR` level.
420-
/// The batch item id comes from the terminal observation so it is unique; span
421-
/// coordinate metadata comes from the start observation.
481+
/// The batch item id comes from the terminal observation so it is unique; the
482+
/// caller supplies the fully-built span `metadata`.
422483
#[allow(clippy::too_many_arguments)]
423484
fn span_event(
424485
trace_id: &str,
@@ -429,7 +490,7 @@ fn span_event(
429490
end_ts: Option<u64>,
430491
error: Option<&str>,
431492
terminal: &GraphObservation,
432-
coords: &GraphObservation,
493+
metadata: Value,
433494
) -> Value {
434495
let start_iso = iso_ms(start_ts);
435496
let end_iso = end_ts.map(iso_ms);
@@ -450,14 +511,22 @@ fn span_event(
450511
"endTime": end_iso,
451512
"level": level,
452513
"statusMessage": status,
453-
"metadata": span_metadata(coords),
514+
"metadata": metadata,
454515
})),
455516
})
456517
}
457518

458-
/// Extracts the correlation coordinates every span/event carries in metadata.
459-
fn span_metadata(obs: &GraphObservation) -> Value {
460-
json!({
519+
/// Extracts the correlation coordinates every span/event carries in metadata,
520+
/// stamping the Langfuse Agent-Graph-view keys (`langgraph_node`,
521+
/// `langgraph_step`) when the caller supplies them and merging any
522+
/// host-injected keys last (host keys win on collision).
523+
fn span_metadata(
524+
obs: &GraphObservation,
525+
langgraph_node: Option<&str>,
526+
langgraph_step: Option<usize>,
527+
injector: Option<&SpanMetadataFn>,
528+
) -> Value {
529+
let mut metadata = json!({
461530
"run_id": obs.run_id.as_str(),
462531
"root_run_id": obs.root_run_id.as_str(),
463532
"parent_run_id": obs.parent_run_id.as_ref().map(|id| id.as_str()),
@@ -467,7 +536,21 @@ fn span_metadata(obs: &GraphObservation) -> Value {
467536
"step": obs.step,
468537
"offset": obs.offset,
469538
"event": obs.event,
470-
})
539+
});
540+
if let Value::Object(map) = &mut metadata {
541+
if let Some(node) = langgraph_node {
542+
map.insert("langgraph_node".to_string(), json!(node));
543+
}
544+
if let Some(step) = langgraph_step {
545+
map.insert("langgraph_step".to_string(), json!(step));
546+
}
547+
if let Some(extra) = injector.and_then(|f| f(obs)) {
548+
for (k, v) in extra {
549+
map.insert(k, v);
550+
}
551+
}
552+
}
553+
metadata
471554
}
472555

473556
/// Pops the FIFO-oldest pending start for `key`, cleaning up empty queues.

0 commit comments

Comments
 (0)