Skip to content

Commit 9c9be1f

Browse files
committed
send back proper summaries
1 parent dc2a32b commit 9c9be1f

5 files changed

Lines changed: 426 additions & 84 deletions

File tree

packages/sdk/server-ai/src/ldai/managed_agent_graph.py

Lines changed: 22 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""ManagedAgentGraph — LaunchDarkly managed wrapper for agent graph execution."""
22

3-
from typing import Any
3+
from typing import Any, Dict
44

55
from ldai.agent_graph import AgentGraphDefinition
66
from ldai.providers import AgentGraphRunner
77
from ldai.providers.types import (
88
AgentGraphRunnerResult,
9-
GraphMetricSummary,
9+
LDAIMetrics,
1010
ManagedGraphResult,
1111
)
12+
from ldai.tracker import LDAIMetricSummary
1213

1314

1415
class ManagedAgentGraph:
@@ -42,24 +43,20 @@ async def run(self, input: Any) -> ManagedGraphResult:
4243
Run the agent graph with the given input.
4344
4445
Delegates to the underlying AgentGraphRunner, then drives all
45-
LaunchDarkly tracking from ``result.metrics``:
46-
47-
- Graph-level events (path, duration, success/failure, total tokens) via
48-
the graph tracker obtained from the graph definition.
49-
- Per-node events (tokens, duration, tool calls, success) via per-node
50-
trackers for each key present in ``result.metrics.node_metrics``.
46+
LaunchDarkly tracking from ``result.metrics``.
5147
5248
:param input: The input prompt or structured input for the graph
5349
:return: ManagedGraphResult containing the content, metric summary,
5450
and raw response.
5551
"""
56-
result = await self._runner.run(input)
57-
58-
summary = self._build_summary_from_runner_result(result)
59-
6052
graph_tracker = self._graph.create_tracker()
61-
self._flush_graph_tracking(result, graph_tracker)
62-
self._flush_node_tracking(result)
53+
result = await graph_tracker.track_graph_metrics_of_async(
54+
lambda r: r.metrics,
55+
lambda: self._runner.run(input),
56+
)
57+
58+
summary = graph_tracker.get_summary()
59+
summary.node_metrics = self._track_node_metrics(result.metrics.node_metrics)
6360

6461
return ManagedGraphResult(
6562
content=result.content,
@@ -68,51 +65,22 @@ async def run(self, input: Any) -> ManagedGraphResult:
6865
evaluations=None,
6966
)
7067

71-
def _build_summary_from_runner_result(
72-
self,
73-
result: AgentGraphRunnerResult,
74-
) -> GraphMetricSummary:
75-
"""Build a GraphMetricSummary from an AgentGraphRunnerResult."""
76-
m = result.metrics
77-
return GraphMetricSummary(
78-
success=m.success,
79-
path=list(m.path),
80-
duration_ms=m.duration_ms,
81-
usage=m.usage,
82-
node_metrics=dict(m.node_metrics),
83-
)
84-
85-
def _flush_graph_tracking(self, result: AgentGraphRunnerResult, tracker: Any) -> None:
86-
"""
87-
Drive graph-level LaunchDarkly tracking events from runner result metrics.
68+
def _track_node_metrics(
69+
self, node_metrics: Dict[str, LDAIMetrics]
70+
) -> Dict[str, LDAIMetricSummary]:
8871
"""
89-
m = result.metrics
90-
if m.path:
91-
tracker.track_path(m.path)
92-
if m.duration_ms is not None:
93-
tracker.track_duration(m.duration_ms)
94-
if m.success:
95-
tracker.track_invocation_success()
96-
else:
97-
tracker.track_invocation_failure()
98-
if m.usage is not None:
99-
tracker.track_total_tokens(m.usage)
100-
101-
def _flush_node_tracking(self, result: AgentGraphRunnerResult) -> None:
102-
"""
103-
Drive per-node LaunchDarkly tracking events from ``result.metrics.node_metrics``.
72+
Drive per-node LaunchDarkly tracking events and collect node metric summaries.
10473
10574
For each node key present in ``node_metrics``, obtains the node's
106-
config tracker via the graph definition and fires token, duration,
107-
tool call, and success/error events.
75+
config tracker via the graph definition, fires tracking events, and
76+
returns a map of node key to the tracker's metric summary.
10877
"""
109-
for node_key, node_ldai_metrics in result.metrics.node_metrics.items():
78+
node_summaries: Dict[str, LDAIMetricSummary] = {}
79+
for node_key, node_ldai_metrics in node_metrics.items():
11080
node = self._graph.get_node(node_key)
11181
if node is None:
11282
continue
11383
node_tracker = node.get_config().create_tracker()
114-
if node_tracker is None:
115-
continue
11684

11785
if node_ldai_metrics.usage is not None:
11886
node_tracker.track_tokens(node_ldai_metrics.usage)
@@ -125,6 +93,9 @@ def _flush_node_tracking(self, result: AgentGraphRunnerResult) -> None:
12593
else:
12694
node_tracker.track_error()
12795

96+
node_summaries[node_key] = node_tracker.get_summary()
97+
return node_summaries
98+
12899
def get_agent_graph_runner(self) -> AgentGraphRunner:
129100
"""
130101
Return the underlying AgentGraphRunner for advanced use.

packages/sdk/server-ai/src/ldai/providers/types.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ class GraphMetrics:
138138
class GraphMetricSummary:
139139
"""Contains a summary of metrics for an agent graph run."""
140140

141-
success: bool
142-
"""Whether the graph run succeeded."""
141+
success: Optional[bool] = None
142+
"""Whether the graph run succeeded. Absent if invocation status has not been tracked."""
143143

144144
path: List[str] = field(default_factory=list)
145145
"""Ordered list of node keys visited during the run."""
@@ -150,8 +150,8 @@ class GraphMetricSummary:
150150
usage: Optional[TokenUsage] = None
151151
"""Optional aggregate token usage information across all nodes in the graph run."""
152152

153-
node_metrics: Dict[str, LDAIMetrics] = field(default_factory=dict)
154-
"""Per-node metrics keyed by node key."""
153+
node_metrics: Dict[str, LDAIMetricSummary] = field(default_factory=dict)
154+
"""Per-node metric summaries keyed by node key."""
155155

156156
resumption_token: Optional[str] = None
157157
"""Optional resumption token from the graph tracker for cross-process resumption."""

packages/sdk/server-ai/src/ldai/tracker.py

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from ldai import log
1414

1515
if TYPE_CHECKING:
16-
from ldai.providers.types import LDAIMetrics
16+
from ldai.providers.types import GraphMetrics, GraphMetricSummary, LDAIMetrics
1717

1818

1919
class FeedbackKind(Enum):
@@ -616,7 +616,11 @@ def _openai_to_token_usage(data: dict) -> TokenUsage:
616616

617617
class AIGraphTracker:
618618
"""
619-
Tracks graph-level, node-level, and edge-level metrics for AI agent graph operations.
619+
Tracks graph-level metrics for AI agent graph operations.
620+
621+
Maintains an internal :class:`~ldai.providers.types.GraphMetricSummary`
622+
that is updated as tracking methods are called. Retrieve it via
623+
:meth:`get_summary`.
620624
"""
621625

622626
def __init__(
@@ -642,11 +646,22 @@ def __init__(
642646
self._version = version
643647
self._context = context
644648

649+
from ldai.providers.types import GraphMetricSummary
650+
self._summary = GraphMetricSummary()
651+
645652
@property
646653
def graph_key(self) -> str:
647654
"""Graph configuration key used in tracking payloads."""
648655
return self._graph_key
649656

657+
def get_summary(self) -> GraphMetricSummary:
658+
"""
659+
Get the current summary of graph-level metrics.
660+
661+
:return: Summary of graph metrics tracked so far.
662+
"""
663+
return self._summary
664+
650665
def __get_track_data(self):
651666
"""
652667
Get tracking data for events.
@@ -664,6 +679,10 @@ def track_invocation_success(self) -> None:
664679
"""
665680
Track a successful graph invocation.
666681
"""
682+
if self._summary.success is not None:
683+
log.warning("Invocation status has already been tracked for this graph execution. %s", self.__get_track_data())
684+
return
685+
self._summary.success = True
667686
self._ld_client.track(
668687
"$ld:ai:graph:invocation_success",
669688
self._context,
@@ -675,6 +694,10 @@ def track_invocation_failure(self) -> None:
675694
"""
676695
Track an unsuccessful graph invocation.
677696
"""
697+
if self._summary.success is not None:
698+
log.warning("Invocation status has already been tracked for this graph execution. %s", self.__get_track_data())
699+
return
700+
self._summary.success = False
678701
self._ld_client.track(
679702
"$ld:ai:graph:invocation_failure",
680703
self._context,
@@ -688,6 +711,10 @@ def track_duration(self, duration: int) -> None:
688711
689712
:param duration: Duration in milliseconds.
690713
"""
714+
if self._summary.duration_ms is not None:
715+
log.warning("Duration has already been tracked for this graph execution. %s", self.__get_track_data())
716+
return
717+
self._summary.duration_ms = duration
691718
self._ld_client.track(
692719
"$ld:ai:graph:duration:total",
693720
self._context,
@@ -703,6 +730,10 @@ def track_total_tokens(self, tokens: Optional[TokenUsage] = None) -> None:
703730
"""
704731
if tokens is None or tokens.total <= 0:
705732
return
733+
if self._summary.usage is not None:
734+
log.warning("Token usage has already been tracked for this graph execution. %s", self.__get_track_data())
735+
return
736+
self._summary.usage = tokens
706737
self._ld_client.track(
707738
"$ld:ai:graph:total_tokens",
708739
self._context,
@@ -716,6 +747,10 @@ def track_path(self, path: List[str]) -> None:
716747
717748
:param path: An array of configuration keys representing the sequence of nodes executed during graph traversal.
718749
"""
750+
if self._summary.path:
751+
log.warning("Path has already been tracked for this graph execution. %s", self.__get_track_data())
752+
return
753+
self._summary.path = list(path)
719754
track_data = {**self.__get_track_data(), "path": path}
720755
self._ld_client.track(
721756
"$ld:ai:graph:path",
@@ -780,3 +815,92 @@ def track_handoff_failure(self, source_key: str, target_key: str) -> None:
780815
track_data,
781816
1,
782817
)
818+
819+
def _track_from_graph_metrics(
820+
self,
821+
result: Any,
822+
metrics_extractor: Callable[[Any], Optional[GraphMetrics]],
823+
elapsed_ms: int,
824+
) -> None:
825+
metrics: Optional[GraphMetrics] = None
826+
try:
827+
metrics = metrics_extractor(result)
828+
except Exception as exc:
829+
log.warning("Failed to extract graph metrics: %s", exc)
830+
831+
if metrics is None:
832+
self.track_duration(elapsed_ms)
833+
return
834+
835+
self.track_duration(metrics.duration_ms if metrics.duration_ms is not None else elapsed_ms)
836+
if metrics.success:
837+
self.track_invocation_success()
838+
else:
839+
self.track_invocation_failure()
840+
if metrics.path:
841+
self.track_path(metrics.path)
842+
if metrics.usage is not None:
843+
self.track_total_tokens(metrics.usage)
844+
845+
def track_graph_metrics_of(
846+
self,
847+
metrics_extractor: Callable[[Any], Optional[GraphMetrics]],
848+
func: Callable[[], Any],
849+
) -> Any:
850+
"""
851+
Track graph-level metrics for a synchronous graph operation.
852+
853+
Times the operation, extracts :class:`~ldai.providers.types.GraphMetrics`
854+
via the provided extractor, and fires graph-level tracking events
855+
(path, duration, success/failure, total tokens).
856+
857+
If the extracted ``GraphMetrics`` has a non-``None`` ``duration_ms``,
858+
that value is used instead of the wall-clock elapsed time.
859+
860+
Node-level metrics are not tracked by this method.
861+
862+
For async operations, use :meth:`track_graph_metrics_of_async`.
863+
864+
:param metrics_extractor: Function that extracts GraphMetrics from the result
865+
:param func: Synchronous callable that runs the graph operation
866+
:return: The result of the operation
867+
"""
868+
start_ns = time.perf_counter_ns()
869+
try:
870+
result = func()
871+
except Exception as err:
872+
duration = (time.perf_counter_ns() - start_ns) // 1_000_000
873+
self.track_duration(duration)
874+
self.track_invocation_failure()
875+
raise err
876+
877+
elapsed_ms = (time.perf_counter_ns() - start_ns) // 1_000_000
878+
self._track_from_graph_metrics(result, metrics_extractor, elapsed_ms)
879+
return result
880+
881+
async def track_graph_metrics_of_async(
882+
self,
883+
metrics_extractor: Callable[[Any], Optional[GraphMetrics]],
884+
func: Callable[[], Any],
885+
) -> Any:
886+
"""
887+
Track graph-level metrics for an async graph operation (``func`` is awaited).
888+
889+
Same event semantics as :meth:`track_graph_metrics_of`.
890+
891+
:param metrics_extractor: Function that extracts GraphMetrics from the result
892+
:param func: Async callable that runs the graph operation
893+
:return: The result of the operation
894+
"""
895+
start_ns = time.perf_counter_ns()
896+
try:
897+
result = await func()
898+
except Exception as err:
899+
duration = (time.perf_counter_ns() - start_ns) // 1_000_000
900+
self.track_duration(duration)
901+
self.track_invocation_failure()
902+
raise err
903+
904+
elapsed_ms = (time.perf_counter_ns() - start_ns) // 1_000_000
905+
self._track_from_graph_metrics(result, metrics_extractor, elapsed_ms)
906+
return result

0 commit comments

Comments
 (0)