Skip to content

Commit aaa2aed

Browse files
authored
Log deeplink to trace on AgentOps dashboard. (#879)
* Log deeplink to trace on AgentOps dashboard. * Test coverage, type checking. * Get app_url from config. * Don't format trace_id in the URL as a UUID, just a hex string.
1 parent bb7aa64 commit aaa2aed

File tree

7 files changed

+320
-57
lines changed

7 files changed

+320
-57
lines changed

agentops/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def record(event):
2828
def init(
2929
api_key: Optional[str] = None,
3030
endpoint: Optional[str] = None,
31+
app_url: Optional[str] = None,
3132
max_wait_time: Optional[int] = None,
3233
max_queue_size: Optional[int] = None,
3334
tags: Optional[List[str]] = None,
@@ -50,6 +51,8 @@ def init(
5051
be read from the AGENTOPS_API_KEY environment variable.
5152
endpoint (str, optional): The endpoint for the AgentOps service. If none is provided, key will
5253
be read from the AGENTOPS_API_ENDPOINT environment variable. Defaults to 'https://api.agentops.ai'.
54+
app_url (str, optional): The dashboard URL for the AgentOps app. If none is provided, key will
55+
be read from the AGENTOPS_APP_URL environment variable. Defaults to 'https://app.agentops.ai'.
5356
max_wait_time (int, optional): The maximum time to wait in milliseconds before flushing the queue.
5457
Defaults to 5,000 (5 seconds)
5558
max_queue_size (int, optional): The maximum size of the event queue. Defaults to 512.
@@ -79,6 +82,7 @@ def init(
7982
return _client.init(
8083
api_key=api_key,
8184
endpoint=endpoint,
85+
app_url=app_url,
8286
max_wait_time=max_wait_time,
8387
max_queue_size=max_queue_size,
8488
default_tags=merged_tags,
@@ -101,6 +105,7 @@ def configure(**kwargs):
101105
**kwargs: Configuration parameters. Supported parameters include:
102106
- api_key: API Key for AgentOps services
103107
- endpoint: The endpoint for the AgentOps service
108+
- app_url: The dashboard URL for the AgentOps app
104109
- max_wait_time: Maximum time to wait in milliseconds before flushing the queue
105110
- max_queue_size: Maximum size of the event queue
106111
- default_tags: Default tags for the sessions
@@ -118,6 +123,7 @@ def configure(**kwargs):
118123
valid_params = {
119124
"api_key",
120125
"endpoint",
126+
"app_url",
121127
"max_wait_time",
122128
"max_queue_size",
123129
"default_tags",

agentops/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
class ConfigDict(TypedDict):
2020
api_key: Optional[str]
2121
endpoint: Optional[str]
22+
app_url: Optional[str]
2223
max_wait_time: Optional[int]
2324
export_flush_interval: Optional[int]
2425
max_queue_size: Optional[int]
@@ -45,6 +46,11 @@ class Config:
4546
metadata={"description": "Base URL for the AgentOps API"},
4647
)
4748

49+
app_url: str = field(
50+
default_factory=lambda: os.getenv("AGENTOPS_APP_URL", "https://app.agentops.ai"),
51+
metadata={"description": "Dashboard URL for the AgentOps application"},
52+
)
53+
4854
max_wait_time: int = field(
4955
default_factory=lambda: get_env_int("AGENTOPS_MAX_WAIT_TIME", 5000),
5056
metadata={"description": "Maximum time in milliseconds to wait for API responses"},
@@ -124,6 +130,7 @@ def configure(
124130
self,
125131
api_key: Optional[str] = None,
126132
endpoint: Optional[str] = None,
133+
app_url: Optional[str] = None,
127134
max_wait_time: Optional[int] = None,
128135
export_flush_interval: Optional[int] = None,
129136
max_queue_size: Optional[int] = None,
@@ -151,6 +158,9 @@ def configure(
151158

152159
if endpoint is not None:
153160
self.endpoint = endpoint
161+
162+
if app_url is not None:
163+
self.app_url = app_url
154164

155165
if max_wait_time is not None:
156166
self.max_wait_time = max_wait_time
@@ -211,6 +221,7 @@ def dict(self):
211221
return {
212222
"api_key": self.api_key,
213223
"endpoint": self.endpoint,
224+
"app_url": self.app_url,
214225
"max_wait_time": self.max_wait_time,
215226
"export_flush_interval": self.export_flush_interval,
216227
"max_queue_size": self.max_queue_size,

agentops/helpers/dashboard.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Helpers for interacting with the AgentOps dashboard.
3+
"""
4+
from typing import Union
5+
from termcolor import colored
6+
from opentelemetry.sdk.trace import Span, ReadableSpan
7+
from agentops.logging import logger
8+
9+
10+
def get_trace_url(span: Union[Span, ReadableSpan]) -> str:
11+
"""
12+
Generate a trace URL for a direct link to the session on the AgentOps dashboard.
13+
14+
Args:
15+
span: The span to generate the URL for.
16+
17+
Returns:
18+
The session URL.
19+
"""
20+
trace_id: Union[int, str] = span.context.trace_id
21+
22+
# Convert trace_id to hex string if it's not already
23+
# We don't add dashes to this to format it as a UUID since the dashboard doesn't either
24+
if isinstance(trace_id, int):
25+
trace_id = format(trace_id, "032x")
26+
27+
# Get the app_url from the config - import here to avoid circular imports
28+
from agentops import get_client
29+
app_url = get_client().config.app_url
30+
31+
return f"{app_url}/sessions?trace_id={trace_id}"
32+
33+
34+
def log_trace_url(span: Union[Span, ReadableSpan]) -> None:
35+
"""
36+
Log the trace URL for the AgentOps dashboard.
37+
38+
Args:
39+
span: The span to log the URL for.
40+
"""
41+
session_url = get_trace_url(span)
42+
logger.info(colored(f"\x1b[34mSession Replay: {session_url}\x1b[0m", "blue"))
43+

agentops/sdk/processors.py

Lines changed: 11 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,17 @@
44
This module contains processors for OpenTelemetry spans.
55
"""
66

7-
import copy
8-
import threading
97
import time
108
from threading import Event, Lock, Thread
11-
from typing import Any, Dict, List, Optional
9+
from typing import Dict, Optional
1210

1311
from opentelemetry.context import Context
1412
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
1513
from opentelemetry.sdk.trace.export import SpanExporter
16-
from termcolor import colored
1714

1815
import agentops.semconv as semconv
1916
from agentops.logging import logger
20-
from agentops.sdk.converters import trace_id_to_uuid, uuid_to_int16
17+
from agentops.helpers.dashboard import log_trace_url
2118
from agentops.semconv.core import CoreAttributes
2219

2320

@@ -89,14 +86,7 @@ class InternalSpanProcessor(SpanProcessor):
8986
For session spans, it prints a URL to the AgentOps dashboard.
9087
"""
9188

92-
def __init__(self, app_url: str = "https://app.agentops.ai"):
93-
"""
94-
Initialize the PrintSpanProcessor.
95-
96-
Args:
97-
app_url: The base URL for the AgentOps dashboard.
98-
"""
99-
self.app_url = app_url
89+
_root_span_id: Optional[Span] = None
10090

10191
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
10292
"""
@@ -110,29 +100,10 @@ def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None
110100
if not span.context or not span.context.trace_flags.sampled:
111101
return
112102

113-
# Get the span kind from attributes
114-
span_kind = (
115-
span.attributes.get(semconv.SpanAttributes.AGENTOPS_SPAN_KIND, "unknown") if span.attributes else "unknown"
116-
)
117-
118-
# Print basic information about the span
119-
logger.debug(f"Started span: {span.name} (kind: {span_kind})")
120-
121-
# Special handling for session spans
122-
if span_kind == semconv.SpanKind.SESSION:
123-
trace_id = span.context.trace_id
124-
# Convert trace_id to hex string if it's not already
125-
if isinstance(trace_id, int):
126-
session_url = f"{self.app_url}/drilldown?session_id={trace_id_to_uuid(trace_id)}"
127-
logger.info(
128-
colored(
129-
f"\x1b[34mSession started: {session_url}\x1b[0m",
130-
"light_green",
131-
)
132-
)
133-
else:
134-
# Print basic information for other span kinds
135-
logger.debug(f"Ended span: {span.name} (kind: {span_kind})")
103+
if not self._root_span_id:
104+
self._root_span_id = span.context.span_id
105+
logger.debug(f"[agentops.InternalSpanProcessor] Found root span: {span.name}")
106+
log_trace_url(span)
136107

137108
def on_end(self, span: ReadableSpan) -> None:
138109
"""
@@ -145,30 +116,13 @@ def on_end(self, span: ReadableSpan) -> None:
145116
if not span.context or not span.context.trace_flags.sampled:
146117
return
147118

148-
# Get the span kind from attributes
149-
span_kind = (
150-
span.attributes.get(semconv.SpanAttributes.AGENTOPS_SPAN_KIND, "unknown") if span.attributes else "unknown"
151-
)
152-
153-
# Special handling for session spans
154-
if span_kind == semconv.SpanKind.SESSION:
155-
trace_id = span.context.trace_id
156-
# Convert trace_id to hex string if it's not already
157-
if isinstance(trace_id, int):
158-
session_url = f"{self.app_url}/drilldown?session_id={trace_id_to_uuid(trace_id)}"
159-
logger.info(
160-
colored(
161-
f"\x1b[34mSession Replay: {session_url}\x1b[0m",
162-
"blue",
163-
)
164-
)
165-
else:
166-
# Print basic information for other span kinds
167-
logger.debug(f"Ended span: {span.name} (kind: {span_kind})")
119+
if self._root_span_id and (span.context.span_id is self._root_span_id):
120+
logger.debug(f"[agentops.InternalSpanProcessor] Ending root span: {span.name}")
121+
log_trace_url(span)
168122

169123
def shutdown(self) -> None:
170124
"""Shutdown the processor."""
171-
pass
125+
self._root_span_id = None
172126

173127
def force_flush(self, timeout_millis: int = 30000) -> bool:
174128
"""Force flush the processor."""
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
Unit tests for dashboard URL generation and logging.
3+
"""
4+
5+
import unittest
6+
from unittest.mock import patch, MagicMock
7+
8+
from agentops.helpers.dashboard import get_trace_url, log_trace_url
9+
10+
11+
class TestDashboardHelpers(unittest.TestCase):
12+
"""Tests for dashboard URL generation and logging functions."""
13+
14+
@patch('agentops.get_client')
15+
def test_get_trace_url_with_hex_trace_id(self, mock_get_client):
16+
"""Test get_trace_url with a hexadecimal trace ID."""
17+
# Mock the config's app_url
18+
mock_client = MagicMock()
19+
mock_client.config.app_url = "https://test-app.agentops.ai"
20+
mock_get_client.return_value = mock_client
21+
22+
# Create a mock span with a hex string trace ID (using a full 32-character trace ID)
23+
mock_span = MagicMock()
24+
mock_span.context.trace_id = "1234567890abcdef1234567890abcdef"
25+
26+
# Call get_trace_url
27+
url = get_trace_url(mock_span)
28+
29+
# Assert that the URL is correctly formed with the config's app_url
30+
self.assertEqual(url, "https://test-app.agentops.ai/sessions?trace_id=1234567890abcdef1234567890abcdef")
31+
32+
@patch('agentops.get_client')
33+
def test_get_trace_url_with_int_trace_id(self, mock_get_client):
34+
"""Test get_trace_url with an integer trace ID."""
35+
# Mock the config's app_url
36+
mock_client = MagicMock()
37+
mock_client.config.app_url = "https://test-app.agentops.ai"
38+
mock_get_client.return_value = mock_client
39+
40+
# Create a mock span with an int trace ID
41+
mock_span = MagicMock()
42+
mock_span.context.trace_id = 12345
43+
44+
# Call get_trace_url
45+
url = get_trace_url(mock_span)
46+
47+
# Assert that the URL follows the expected format with a 32-character hex string
48+
self.assertTrue(url.startswith("https://test-app.agentops.ai/sessions?trace_id="))
49+
50+
# Verify the format is a 32-character hex string (no dashes)
51+
hex_part = url.split("trace_id=")[1]
52+
self.assertRegex(hex_part, r"^[0-9a-f]{32}$")
53+
54+
# Verify the value is correctly formatted from the integer 12345
55+
expected_hex = format(12345, "032x")
56+
self.assertEqual(hex_part, expected_hex)
57+
58+
@patch('agentops.helpers.dashboard.logger')
59+
@patch('agentops.get_client')
60+
def test_log_trace_url(self, mock_get_client, mock_logger):
61+
"""Test log_trace_url includes the session URL in the log message."""
62+
# Mock the config's app_url
63+
mock_client = MagicMock()
64+
mock_client.config.app_url = "https://test-app.agentops.ai"
65+
mock_get_client.return_value = mock_client
66+
67+
# Create a mock span
68+
mock_span = MagicMock()
69+
mock_span.context.trace_id = "test-trace-id"
70+
71+
# Mock get_trace_url to return a known value that uses the app_url
72+
expected_url = "https://test-app.agentops.ai/sessions?trace_id=test-trace-id"
73+
with patch('agentops.helpers.dashboard.get_trace_url', return_value=expected_url):
74+
# Call log_trace_url
75+
log_trace_url(mock_span)
76+
77+
# Assert that logger.info was called with a message containing the URL
78+
mock_logger.info.assert_called_once()
79+
log_message = mock_logger.info.call_args[0][0]
80+
self.assertIn(expected_url, log_message)

0 commit comments

Comments
 (0)