44
55from langchain_core .callbacks import BaseCallbackHandler
66from langchain_core .outputs import ChatGeneration , LLMResult
7- from ldai .agent_graph import AgentGraphDefinition
8- from ldai .providers .types import JudgeResult
7+ from ldai .providers .types import LDAIMetrics
98from ldai .tracker import TokenUsage
109
1110from ldai_langchain .langchain_helper import get_ai_usage_from_response
@@ -20,8 +19,10 @@ class LDMetricsCallbackHandler(BaseCallbackHandler):
2019
2120 LangChain callback handler that collects per-node metrics during a LangGraph run.
2221
23- Records token usage, tool calls, and duration for each agent node in the graph,
24- then flushes them to LaunchDarkly trackers after the run completes via ``flush()``.
22+ Records token usage, tool calls, and duration for each agent node in the graph.
23+ Each node's :class:`~ldai.providers.types.LDAIMetrics` is built incrementally
24+ as callbacks fire. Access the ``node_metrics`` property after the run completes
25+ to retrieve the accumulated per-node metrics.
2526 """
2627
2728 def __init__ (self , node_keys : Set [str ], fn_name_to_config_key : Dict [str , str ]):
@@ -39,14 +40,10 @@ def __init__(self, node_keys: Set[str], fn_name_to_config_key: Dict[str, str]):
3940
4041 # run_id -> node_key for active chain runs
4142 self ._run_to_node : Dict [UUID , str ] = {}
42- # accumulated token usage per node
43- self ._node_tokens : Dict [str , TokenUsage ] = {}
44- # tool config keys called per node
45- self ._node_tool_calls : Dict [str , List [str ]] = {}
4643 # start time (ns) per active run_id — keyed by run_id to handle re-entrant nodes
4744 self ._node_start_ns : Dict [UUID , int ] = {}
48- # accumulated duration (ms) per node
49- self ._node_duration_ms : Dict [str , int ] = {}
45+ # per-node metrics, built incrementally as callbacks fire
46+ self ._node_metrics : Dict [str , LDAIMetrics ] = {}
5047 # execution path in order (deduplicated)
5148 self ._path : List [str ] = []
5249 self ._path_set : Set [str ] = set ()
@@ -61,19 +58,9 @@ def path(self) -> List[str]:
6158 return list (self ._path )
6259
6360 @property
64- def node_tokens (self ) -> Dict [str , TokenUsage ]:
65- """Accumulated token usage per node key."""
66- return dict (self ._node_tokens )
67-
68- @property
69- def node_tool_calls (self ) -> Dict [str , List [str ]]:
70- """Tool config keys called per node key."""
71- return {k : list (v ) for k , v in self ._node_tool_calls .items ()}
72-
73- @property
74- def node_durations_ms (self ) -> Dict [str , int ]:
75- """Accumulated duration in milliseconds per node key."""
76- return dict (self ._node_duration_ms )
61+ def node_metrics (self ) -> Dict [str , LDAIMetrics ]:
62+ """Per-node metrics keyed by node key."""
63+ return dict (self ._node_metrics )
7764
7865 # ------------------------------------------------------------------
7966 # Callbacks
@@ -101,10 +88,10 @@ def on_chain_start(
10188 if name not in self ._path_set :
10289 self ._path .append (name )
10390 self ._path_set .add (name )
91+ self ._node_metrics [name ] = LDAIMetrics (success = False )
10492 elif name .endswith ('__tools' ):
10593 stripped = name [: - len ('__tools' )]
10694 if stripped in self ._node_keys :
107- # Attribute tool events to the owning agent node
10895 self ._run_to_node [run_id ] = stripped
10996
11097 def on_chain_end (
@@ -121,9 +108,10 @@ def on_chain_end(
121108 start_ns = self ._node_start_ns .pop (run_id , None )
122109 if start_ns is not None :
123110 elapsed_ms = (time .perf_counter_ns () - start_ns ) // 1_000_000
124- self ._node_duration_ms [node_key ] = (
125- self ._node_duration_ms .get (node_key , 0 ) + elapsed_ms
126- )
111+ metrics = self ._node_metrics .get (node_key )
112+ if metrics is not None :
113+ metrics .success = True
114+ metrics .duration_ms = (metrics .duration_ms or 0 ) + elapsed_ms
127115
128116 def on_llm_end (
129117 self ,
@@ -151,11 +139,14 @@ def on_llm_end(
151139 if usage is None :
152140 return
153141
154- existing = self ._node_tokens .get (node_key )
142+ metrics = self ._node_metrics .get (node_key )
143+ if metrics is None :
144+ return
145+ existing = metrics .usage
155146 if existing is None :
156- self . _node_tokens [ node_key ] = usage
147+ metrics . usage = usage
157148 else :
158- self . _node_tokens [ node_key ] = TokenUsage (
149+ metrics . usage = TokenUsage (
159150 total = existing .total + usage .total ,
160151 input = existing .input + usage .input ,
161152 output = existing .output + usage .output ,
@@ -179,64 +170,11 @@ def on_tool_end(
179170
180171 config_key = self ._fn_name_to_config_key .get (name )
181172 if config_key is None :
182- # Tool is not a registered functional tool (e.g. a handoff tool) — skip tracking.
183173 return
184- if node_key not in self ._node_tool_calls :
185- self ._node_tool_calls [node_key ] = []
186- self ._node_tool_calls [node_key ].append (config_key )
187-
188- # ------------------------------------------------------------------
189- # Flush
190- # ------------------------------------------------------------------
191-
192- async def flush (
193- self , graph : AgentGraphDefinition , eval_tasks = None
194- ) -> List [JudgeResult ]:
195- """
196- Emit all collected per-node metrics to the LaunchDarkly trackers.
197-
198- Call this once after the graph run completes.
199-
200- :param graph: The AgentGraphDefinition whose nodes hold the LD config trackers.
201- :param eval_tasks: Optional dict mapping node key to a list of awaitables that
202- return judge evaluation results. Multiple tasks arise when a node is visited
203- more than once (e.g. in a graph with cycles).
204- :return: All judge results collected across all nodes.
205- """
206- node_trackers : Dict [str , Any ] = {}
207- all_eval_results : List [JudgeResult ] = []
208- for node_key in self ._path :
209- if node_key in node_trackers :
210- continue
211- node = graph .get_node (node_key )
212- if not node :
213- continue
214- config_tracker = node .get_config ().create_tracker ()
215- if not config_tracker :
216- continue
217- node_trackers [node_key ] = config_tracker
218-
219- usage = self ._node_tokens .get (node_key )
220- if usage :
221- config_tracker .track_tokens (usage )
222-
223- duration = self ._node_duration_ms .get (node_key )
224- if duration is not None :
225- config_tracker .track_duration (duration )
226-
227- config_tracker .track_success ()
228-
229- for tool_key in self ._node_tool_calls .get (node_key , []):
230- config_tracker .track_tool_call (tool_key )
231-
232- if not eval_tasks :
233- continue
234-
235- for eval_task in eval_tasks .get (node_key , []):
236- results = await eval_task
237- all_eval_results .extend (results )
238- for r in results :
239- if r .success :
240- config_tracker .track_judge_result (r )
241-
242- return all_eval_results
174+ metrics = self ._node_metrics .get (node_key )
175+ if metrics is None :
176+ return
177+ if metrics .tool_calls is None :
178+ metrics .tool_calls = [config_key ]
179+ else :
180+ metrics .tool_calls .append (config_key )
0 commit comments