Skip to content

Commit 8f30b0a

Browse files
committed
[REL-12055] feat: add agent graph tracker
1 parent 690e72b commit 8f30b0a

4 files changed

Lines changed: 277 additions & 3 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Edge, JudgeConfiguration, LDAIAgent, LDAIAgentConfig, LDAIAgentDefaults,
1414
LDMessage, ModelConfig, ProviderConfig)
1515
from ldai.providers.types import EvalScore, JudgeResponse
16+
from ldai.tracker import AIGraphTracker
1617

1718
__all__ = [
1819
'LDAIClient',
@@ -21,6 +22,7 @@
2122
'AIAgentConfigRequest',
2223
'AIAgents',
2324
'AIAgentGraphConfig',
25+
'AIGraphTracker',
2426
'Edge',
2527
'AICompletionConfig',
2628
'AICompletionConfigDefault',

packages/sdk/server-ai/src/ldai/agent_graph/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
"""Graph implementation for managing AI agent graphs."""
22

33
from dataclasses import dataclass
4-
from typing import Any, Callable, Dict, List, Optional, Set
4+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set
55

66
from ldclient import Context
77

88
from ldai.models import AIAgentConfig, AIAgentGraphConfig, Edge
99

10+
if TYPE_CHECKING:
11+
from ldai.tracker import AIGraphTracker
12+
1013
DEFAULT_FALSE = AIAgentConfig(key="", enabled=False)
1114

1215

@@ -54,11 +57,21 @@ def __init__(
5457
nodes: Dict[str, AgentGraphNode],
5558
context: Context,
5659
enabled: bool,
60+
tracker: Optional["AIGraphTracker"] = None,
5761
):
5862
self._agent_graph = agent_graph
5963
self._context = context
6064
self._nodes = nodes
6165
self.enabled = enabled
66+
self._tracker = tracker
67+
68+
def get_tracker(self) -> Optional["AIGraphTracker"]:
69+
"""
70+
Get the graph tracker for this graph definition.
71+
72+
:return: The AIGraphTracker instance, or None if not available.
73+
"""
74+
return self._tracker
6275

6376
def is_enabled(self) -> bool:
6477
"""Check if the graph is enabled."""

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
JudgeConfiguration, LDMessage, ModelConfig,
1616
ProviderConfig)
1717
from ldai.providers.ai_provider_factory import AIProviderFactory
18-
from ldai.tracker import LDAIConfigTracker
18+
from ldai.tracker import AIGraphTracker, LDAIConfigTracker
1919

2020

2121
class LDAIClient:
@@ -435,6 +435,19 @@ def agent_graph(
435435
"""
436436
variation = self._client.variation(key, context, {})
437437

438+
# Extract variation metadata for tracker
439+
variation_key = variation.get("_ldMeta", {}).get("variationKey", "")
440+
version = int(variation.get("_ldMeta", {}).get("version", 1))
441+
442+
# Create graph tracker
443+
tracker = AIGraphTracker(
444+
self._client,
445+
variation_key,
446+
key,
447+
version,
448+
context,
449+
)
450+
438451
if not variation.get("root"):
439452
log.debug(f"Agent graph {key} is disabled, no root config key found")
440453
return AgentGraphDefinition(
@@ -447,6 +460,7 @@ def agent_graph(
447460
nodes={},
448461
context=context,
449462
enabled=False,
463+
tracker=tracker,
450464
)
451465

452466
edge_keys = list[str](variation.get("edges", {}).keys())
@@ -474,6 +488,7 @@ def agent_graph(
474488
nodes={},
475489
context=context,
476490
enabled=False,
491+
tracker=tracker,
477492
)
478493

479494
try:
@@ -504,6 +519,7 @@ def agent_graph(
504519
nodes={},
505520
context=context,
506521
enabled=False,
522+
tracker=tracker,
507523
)
508524

509525
nodes = AgentGraphDefinition.build_nodes(
@@ -516,6 +532,7 @@ def agent_graph(
516532
nodes=nodes,
517533
context=context,
518534
enabled=agent_graph_config.enabled,
535+
tracker=tracker,
519536
)
520537

521538
def agents(

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

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import time
22
from dataclasses import dataclass
33
from enum import Enum
4-
from typing import Any, Dict, Optional
4+
from typing import Any, Dict, List, Optional
55

66
from ldclient import Context, LDClient
77

@@ -407,3 +407,245 @@ def _openai_to_token_usage(data: dict) -> TokenUsage:
407407
input=data.get("prompt_tokens", 0),
408408
output=data.get("completion_tokens", 0),
409409
)
410+
411+
412+
class AIGraphTracker:
413+
"""
414+
Tracks graph-level, node-level, and edge-level metrics for AI agent graph operations.
415+
"""
416+
417+
def __init__(
418+
self,
419+
ld_client: LDClient,
420+
variation_key: str,
421+
graph_key: str,
422+
version: int,
423+
context: Context,
424+
):
425+
"""
426+
Initialize an AI Graph tracker.
427+
428+
:param ld_client: LaunchDarkly client instance.
429+
:param variation_key: Variation key for tracking.
430+
:param graph_key: Graph configuration key for tracking.
431+
:param version: Version of the variation.
432+
:param context: Context for evaluation.
433+
"""
434+
self._ld_client = ld_client
435+
self._variation_key = variation_key
436+
self._graph_key = graph_key
437+
self._version = version
438+
self._context = context
439+
440+
def __get_track_data(self):
441+
"""
442+
Get tracking data for events.
443+
444+
:return: Dictionary containing variation, graph key, and version.
445+
"""
446+
track_data = {
447+
"variationKey": self._variation_key,
448+
"graphKey": self._graph_key,
449+
"version": self._version,
450+
}
451+
# Note: aiSdkName and aiSdkVersion are optional and not included for now
452+
return track_data
453+
454+
def track_invocation_success(self) -> None:
455+
"""
456+
Track a successful graph invocation.
457+
"""
458+
self._ld_client.track(
459+
"$ld:ai:graph:invocation_success",
460+
self._context,
461+
self.__get_track_data(),
462+
1,
463+
)
464+
465+
def track_invocation_failure(self) -> None:
466+
"""
467+
Track an unsuccessful graph invocation.
468+
"""
469+
self._ld_client.track(
470+
"$ld:ai:graph:invocation_failure",
471+
self._context,
472+
self.__get_track_data(),
473+
1,
474+
)
475+
476+
def track_latency(self, duration: int) -> None:
477+
"""
478+
Track the total latency of graph execution.
479+
480+
:param duration: Duration in milliseconds.
481+
"""
482+
self._ld_client.track(
483+
"$ld:ai:graph:latency",
484+
self._context,
485+
self.__get_track_data(),
486+
duration,
487+
)
488+
489+
def track_total_tokens(self, tokens: TokenUsage) -> None:
490+
"""
491+
Track aggregated token usage across the entire graph invocation.
492+
493+
:param tokens: Token usage data.
494+
"""
495+
self._ld_client.track(
496+
"$ld:ai:graph:total_tokens",
497+
self._context,
498+
self.__get_track_data(),
499+
tokens.total,
500+
)
501+
502+
def track_path(self, path: List[str]) -> None:
503+
"""
504+
Track the execution path through the graph.
505+
506+
:param path: An array of configuration keys representing the sequence of nodes executed during graph traversal.
507+
"""
508+
track_data = {**self.__get_track_data(), "path": path}
509+
self._ld_client.track(
510+
"$ld:ai:graph:path",
511+
self._context,
512+
track_data,
513+
1,
514+
)
515+
516+
def track_judge_response(self, response: Any) -> None:
517+
"""
518+
Track judge responses for the final graph output.
519+
520+
:param response: JudgeResponse object containing evals and success status.
521+
"""
522+
from ldai.providers.types import EvalScore, JudgeResponse
523+
524+
if isinstance(response, JudgeResponse):
525+
if response.evals:
526+
track_data = self.__get_track_data()
527+
if response.judge_config_key:
528+
track_data = {**track_data, "judgeConfigKey": response.judge_config_key}
529+
530+
for metric_key, eval_score in response.evals.items():
531+
if isinstance(eval_score, EvalScore):
532+
self._ld_client.track(
533+
metric_key,
534+
self._context,
535+
track_data,
536+
eval_score.score,
537+
)
538+
539+
def track_node_invocation(self, config_key: str) -> None:
540+
"""
541+
Track when a node is invoked during graph execution.
542+
543+
:param config_key: The configuration key of the node being invoked.
544+
"""
545+
track_data = {**self.__get_track_data(), "configKey": config_key}
546+
self._ld_client.track(
547+
"$ld:ai:graph:node_invocation",
548+
self._context,
549+
track_data,
550+
1,
551+
)
552+
553+
def track_tool_call(self, config_key: str, tool_key: str) -> None:
554+
"""
555+
Track tool calls made by nodes during graph execution.
556+
557+
:param config_key: The configuration key of the node making the tool call.
558+
:param tool_key: The key of the tool being called.
559+
"""
560+
track_data = {
561+
**self.__get_track_data(),
562+
"configKey": config_key,
563+
"toolKey": tool_key,
564+
}
565+
self._ld_client.track(
566+
"$ld:ai:graph:tool_call",
567+
self._context,
568+
track_data,
569+
1,
570+
)
571+
572+
def track_node_judge_response(self, config_key: str, response: Any) -> None:
573+
"""
574+
Track judge responses for a specific node.
575+
576+
:param config_key: The configuration key of the node being evaluated.
577+
:param response: JudgeResponse object containing evals and success status.
578+
"""
579+
from ldai.providers.types import EvalScore, JudgeResponse
580+
581+
if isinstance(response, JudgeResponse):
582+
if response.evals:
583+
track_data = {**self.__get_track_data(), "configKey": config_key}
584+
if response.judge_config_key:
585+
track_data = {**track_data, "judgeConfigKey": response.judge_config_key}
586+
587+
for metric_key, eval_score in response.evals.items():
588+
if isinstance(eval_score, EvalScore):
589+
self._ld_client.track(
590+
metric_key,
591+
self._context,
592+
track_data,
593+
eval_score.score,
594+
)
595+
596+
def track_redirect(self, source_key: str, redirected_target: str) -> None:
597+
"""
598+
Track when a node redirects to a different target than originally specified.
599+
600+
:param source_key: The configuration key of the source node.
601+
:param redirected_target: The configuration key of the target node that was redirected to.
602+
"""
603+
track_data = {
604+
**self.__get_track_data(),
605+
"sourceKey": source_key,
606+
"redirectedTarget": redirected_target,
607+
}
608+
self._ld_client.track(
609+
"$ld:ai:graph:redirect",
610+
self._context,
611+
track_data,
612+
1,
613+
)
614+
615+
def track_handoff_success(self, source_key: str, target_key: str) -> None:
616+
"""
617+
Track successful handoffs between nodes.
618+
619+
:param source_key: The configuration key of the source node.
620+
:param target_key: The configuration key of the target node.
621+
"""
622+
track_data = {
623+
**self.__get_track_data(),
624+
"sourceKey": source_key,
625+
"targetKey": target_key,
626+
}
627+
self._ld_client.track(
628+
"$ld:ai:graph:handoff_success",
629+
self._context,
630+
track_data,
631+
1,
632+
)
633+
634+
def track_handoff_failure(self, source_key: str, target_key: str) -> None:
635+
"""
636+
Track failed handoffs between nodes.
637+
638+
:param source_key: The configuration key of the source node.
639+
:param target_key: The configuration key of the target node.
640+
"""
641+
track_data = {
642+
**self.__get_track_data(),
643+
"sourceKey": source_key,
644+
"targetKey": target_key,
645+
}
646+
self._ld_client.track(
647+
"$ld:ai:graph:handoff_failure",
648+
self._context,
649+
track_data,
650+
1,
651+
)

0 commit comments

Comments
 (0)