2525
2626use std:: collections:: HashMap ;
2727use std:: collections:: VecDeque ;
28+ use std:: sync:: Arc ;
2829
29- use serde_json:: { Value , json} ;
30+ use serde_json:: { Map , Value , json} ;
3031
3132use crate :: error:: { Result , TinyAgentsError } ;
3233use 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.
4041type 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 ) ]
4855pub 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
5273impl 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) ]
352403fn 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) ]
375432fn 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) ]
423484fn 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