Skip to content

Commit 9d371fa

Browse files
feat: add agent graph tracker (#89)
**Requirements** - [ ] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [ ] I have validated my changes against all supported platform versions **Describe the solution you've provided** Implements the `AIGraphTracker` class to handle tracking events for agent graphs in the SDK. **Describe alternatives you've considered** This is the primary implementation of the tracker. It's broken into its own tracker rather than polluting the interface of the BaseTracker or AiConfigTracker since this is a separate entity that has its own interaction & metric patterns. **Additional context** This implements the following events to be tracked: ``` Edge-level metrics $ld:ai:graph:redirect<{ sourceKey, redirectedTarget }> $ld:ai:graph:handoff_success<{ sourceKey, targetKey }> $ld:ai:graph:handoff_failure<{ sourceKey, targetKey }> Node level metrics $ld:ai:graph:node_invocation<{ graphKey, configKey }> $ld:ai:graph:tool_call<{ graphKey, configKey, toolKey }> <judge_metrics><{ graphKey, configKey }> (judge on a specific node) Graph metrics $ld:ai:graph:invocation_success $ld:ai:graph:invocation_failure $ld:ai:graph:latency (total latency of the entire graph invocation) $ld:ai:graph:total_tokens (total token usage of the graph invocation) $ld:ai:graph:path<{ graphKey, ...configKey }> <judge_metrics><{ graphKey }> (judges on final output) ``` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new LaunchDarkly `track()` event emission for agent graph executions and wires it into `LDAIClient.agent_graph`, increasing telemetry volume and coupling to variation metadata; incorrect usage could affect metrics quality but not core evaluation behavior. > > **Overview** > Adds a new `AIGraphTracker` to emit LaunchDarkly tracking events for agent graph executions (graph invocation success/failure, latency, total tokens, execution path, node invocations/tool calls, redirects/handoffs, and judge metric events). > > `LDAIClient.agent_graph()` now constructs an `AIGraphTracker` from flag `_ldMeta` (variation key/version) and returns it on the `AgentGraphDefinition` (including disabled/invalid graph returns), which also gains a `tracker` field plus `get_tracker()` accessor. `AIGraphTracker` is exported from `ldai.__init__` for SDK consumers. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e6c1ff3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents 3b485fc + e6c1ff3 commit 9d371fa

4 files changed

Lines changed: 273 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: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""Graph implementation for managing AI agent graphs."""
22

3-
from dataclasses import dataclass
43
from typing import Any, Callable, Dict, List, Optional, Set
54

65
from ldclient import Context
76

87
from ldai.models import AIAgentConfig, AIAgentGraphConfig, Edge
8+
from ldai.tracker import AIGraphTracker
99

1010
DEFAULT_FALSE = AIAgentConfig(key="", enabled=False)
1111

@@ -54,11 +54,21 @@ def __init__(
5454
nodes: Dict[str, AgentGraphNode],
5555
context: Context,
5656
enabled: bool,
57+
tracker: Optional[AIGraphTracker] = None,
5758
):
5859
self._agent_graph = agent_graph
5960
self._context = context
6061
self._nodes = nodes
6162
self.enabled = enabled
63+
self._tracker = tracker
64+
65+
def get_tracker(self) -> Optional[AIGraphTracker]:
66+
"""
67+
Get the graph tracker for this graph definition.
68+
69+
:return: The AIGraphTracker instance, or None if not available.
70+
"""
71+
return self._tracker
6272

6373
def is_enabled(self) -> bool:
6474
"""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
@@ -16,7 +16,7 @@
1616
ProviderConfig)
1717
from ldai.providers.ai_provider_factory import AIProviderFactory
1818
from ldai.sdk_info import AI_SDK_LANGUAGE, AI_SDK_NAME, AI_SDK_VERSION
19-
from ldai.tracker import LDAIConfigTracker
19+
from ldai.tracker import AIGraphTracker, LDAIConfigTracker
2020

2121
_TRACK_SDK_INFO = '$ld:ai:sdk:info'
2222
_TRACK_USAGE_COMPLETION_CONFIG = '$ld:ai:usage:completion-config'
@@ -474,6 +474,19 @@ def agent_graph(
474474
"""
475475
variation = self._client.variation(key, context, {})
476476

477+
# Extract variation metadata for tracker
478+
variation_key = variation.get("_ldMeta", {}).get("variationKey", "")
479+
version = int(variation.get("_ldMeta", {}).get("version", 1))
480+
481+
# Create graph tracker
482+
tracker = AIGraphTracker(
483+
self._client,
484+
variation_key,
485+
key,
486+
version,
487+
context,
488+
)
489+
477490
if not variation.get("root"):
478491
log.debug(f"Agent graph {key} is disabled, no root config key found")
479492
return AgentGraphDefinition(
@@ -486,6 +499,7 @@ def agent_graph(
486499
nodes={},
487500
context=context,
488501
enabled=False,
502+
tracker=tracker,
489503
)
490504

491505
edge_keys = list[str](variation.get("edges", {}).keys())
@@ -513,6 +527,7 @@ def agent_graph(
513527
nodes={},
514528
context=context,
515529
enabled=False,
530+
tracker=tracker,
516531
)
517532

518533
try:
@@ -543,6 +558,7 @@ def agent_graph(
543558
nodes={},
544559
context=context,
545560
enabled=False,
561+
tracker=tracker,
546562
)
547563

548564
nodes = AgentGraphDefinition.build_nodes(
@@ -555,6 +571,7 @@ def agent_graph(
555571
nodes=nodes,
556572
context=context,
557573
enabled=agent_graph_config.enabled,
574+
tracker=tracker,
558575
)
559576

560577
def agents(

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

Lines changed: 242 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,244 @@ 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+
return track_data
452+
453+
def track_invocation_success(self) -> None:
454+
"""
455+
Track a successful graph invocation.
456+
"""
457+
self._ld_client.track(
458+
"$ld:ai:graph:invocation_success",
459+
self._context,
460+
self.__get_track_data(),
461+
1,
462+
)
463+
464+
def track_invocation_failure(self) -> None:
465+
"""
466+
Track an unsuccessful graph invocation.
467+
"""
468+
self._ld_client.track(
469+
"$ld:ai:graph:invocation_failure",
470+
self._context,
471+
self.__get_track_data(),
472+
1,
473+
)
474+
475+
def track_latency(self, duration: int) -> None:
476+
"""
477+
Track the total latency of graph execution.
478+
479+
:param duration: Duration in milliseconds.
480+
"""
481+
self._ld_client.track(
482+
"$ld:ai:graph:latency",
483+
self._context,
484+
self.__get_track_data(),
485+
duration,
486+
)
487+
488+
def track_total_tokens(self, tokens: TokenUsage) -> None:
489+
"""
490+
Track aggregated token usage across the entire graph invocation.
491+
492+
:param tokens: Token usage data.
493+
"""
494+
self._ld_client.track(
495+
"$ld:ai:graph:total_tokens",
496+
self._context,
497+
self.__get_track_data(),
498+
tokens.total,
499+
)
500+
501+
def track_path(self, path: List[str]) -> None:
502+
"""
503+
Track the execution path through the graph.
504+
505+
:param path: An array of configuration keys representing the sequence of nodes executed during graph traversal.
506+
"""
507+
track_data = {**self.__get_track_data(), "path": path}
508+
self._ld_client.track(
509+
"$ld:ai:graph:path",
510+
self._context,
511+
track_data,
512+
1,
513+
)
514+
515+
def track_judge_response(self, response: Any) -> None:
516+
"""
517+
Track judge responses for the final graph output.
518+
519+
:param response: JudgeResponse object containing evals and success status.
520+
"""
521+
from ldai.providers.types import EvalScore, JudgeResponse
522+
523+
if isinstance(response, JudgeResponse):
524+
if response.evals:
525+
track_data = self.__get_track_data()
526+
if response.judge_config_key:
527+
track_data = {**track_data, "judgeConfigKey": response.judge_config_key}
528+
529+
for metric_key, eval_score in response.evals.items():
530+
if isinstance(eval_score, EvalScore):
531+
self._ld_client.track(
532+
metric_key,
533+
self._context,
534+
track_data,
535+
eval_score.score,
536+
)
537+
538+
def track_node_invocation(self, config_key: str) -> None:
539+
"""
540+
Track when a node is invoked during graph execution.
541+
542+
:param config_key: The configuration key of the node being invoked.
543+
"""
544+
track_data = {**self.__get_track_data(), "configKey": config_key}
545+
self._ld_client.track(
546+
"$ld:ai:graph:node_invocation",
547+
self._context,
548+
track_data,
549+
1,
550+
)
551+
552+
def track_tool_call(self, config_key: str, tool_key: str) -> None:
553+
"""
554+
Track tool calls made by nodes during graph execution.
555+
556+
:param config_key: The configuration key of the node making the tool call.
557+
:param tool_key: The key of the tool being called.
558+
"""
559+
track_data = {
560+
**self.__get_track_data(),
561+
"configKey": config_key,
562+
"toolKey": tool_key,
563+
}
564+
self._ld_client.track(
565+
"$ld:ai:graph:tool_call",
566+
self._context,
567+
track_data,
568+
1,
569+
)
570+
571+
def track_node_judge_response(self, config_key: str, response: Any) -> None:
572+
"""
573+
Track judge responses for a specific node.
574+
575+
:param config_key: The configuration key of the node being evaluated.
576+
:param response: JudgeResponse object containing evals and success status.
577+
"""
578+
from ldai.providers.types import EvalScore, JudgeResponse
579+
580+
if isinstance(response, JudgeResponse):
581+
if response.evals:
582+
track_data = {**self.__get_track_data(), "configKey": config_key}
583+
if response.judge_config_key:
584+
track_data = {**track_data, "judgeConfigKey": response.judge_config_key}
585+
586+
for metric_key, eval_score in response.evals.items():
587+
if isinstance(eval_score, EvalScore):
588+
self._ld_client.track(
589+
metric_key,
590+
self._context,
591+
track_data,
592+
eval_score.score,
593+
)
594+
595+
def track_redirect(self, source_key: str, redirected_target: str) -> None:
596+
"""
597+
Track when a node redirects to a different target than originally specified.
598+
599+
:param source_key: The configuration key of the source node.
600+
:param redirected_target: The configuration key of the target node that was redirected to.
601+
"""
602+
track_data = {
603+
**self.__get_track_data(),
604+
"sourceKey": source_key,
605+
"redirectedTarget": redirected_target,
606+
}
607+
self._ld_client.track(
608+
"$ld:ai:graph:redirect",
609+
self._context,
610+
track_data,
611+
1,
612+
)
613+
614+
def track_handoff_success(self, source_key: str, target_key: str) -> None:
615+
"""
616+
Track successful handoffs between nodes.
617+
618+
:param source_key: The configuration key of the source node.
619+
:param target_key: The configuration key of the target node.
620+
"""
621+
track_data = {
622+
**self.__get_track_data(),
623+
"sourceKey": source_key,
624+
"targetKey": target_key,
625+
}
626+
self._ld_client.track(
627+
"$ld:ai:graph:handoff_success",
628+
self._context,
629+
track_data,
630+
1,
631+
)
632+
633+
def track_handoff_failure(self, source_key: str, target_key: str) -> None:
634+
"""
635+
Track failed handoffs between nodes.
636+
637+
:param source_key: The configuration key of the source node.
638+
:param target_key: The configuration key of the target node.
639+
"""
640+
track_data = {
641+
**self.__get_track_data(),
642+
"sourceKey": source_key,
643+
"targetKey": target_key,
644+
}
645+
self._ld_client.track(
646+
"$ld:ai:graph:handoff_failure",
647+
self._context,
648+
track_data,
649+
1,
650+
)

0 commit comments

Comments
 (0)