Skip to content

Commit cc14de9

Browse files
committed
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 ef7dc3e commit cc14de9

File tree

7 files changed

+320
-61
lines changed

7 files changed

+320
-61
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 & 61 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

@@ -94,14 +91,7 @@ class InternalSpanProcessor(SpanProcessor):
9491
- This processor tries to use the native kind first, then falls back to the attribute
9592
"""
9693

97-
def __init__(self, app_url: str = "https://app.agentops.ai"):
98-
"""
99-
Initialize the PrintSpanProcessor.
100-
101-
Args:
102-
app_url: The base URL for the AgentOps dashboard.
103-
"""
104-
self.app_url = app_url
94+
_root_span_id: Optional[Span] = None
10595

10696
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
10797
"""
@@ -115,31 +105,10 @@ def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None
115105
if not span.context or not span.context.trace_flags.sampled:
116106
return
117107

118-
# Get the span kind from the span.kind property or the attributes
119-
span_kind = span.kind.name if hasattr(span, "kind") else (
120-
span.attributes.get(semconv.SpanAttributes.AGENTOPS_SPAN_KIND, "unknown") if span.attributes else "unknown"
121-
)
122-
123-
# Print basic information about the span
124-
logger.debug(f"Started span: {span.name} (kind: {span_kind})")
125-
126-
# Special handling for session spans
127-
if span_kind == semconv.SpanKind.SESSION:
128-
trace_id = span.context.trace_id
129-
# Convert trace_id to hex string if it's not already
130-
if isinstance(trace_id, int):
131-
session_url = f"{self.app_url}/drilldown?session_id={trace_id_to_uuid(trace_id)}"
132-
logger.info(
133-
colored(
134-
f"\x1b[34mSession started: {session_url}\x1b[0m",
135-
"light_green",
136-
)
137-
)
138-
else:
139-
# Print basic information for other span kinds
140-
# For native OpenTelemetry SpanKind values (INTERNAL, CLIENT, CONSUMER, etc.),
141-
# we'll see the actual kind rather than "unknown"
142-
logger.debug(f"Started span: {span.name} (kind: {span_kind})")
108+
if not self._root_span_id:
109+
self._root_span_id = span.context.span_id
110+
logger.debug(f"[agentops.InternalSpanProcessor] Found root span: {span.name}")
111+
log_trace_url(span)
143112

144113
def on_end(self, span: ReadableSpan) -> None:
145114
"""
@@ -152,32 +121,13 @@ def on_end(self, span: ReadableSpan) -> None:
152121
if not span.context or not span.context.trace_flags.sampled:
153122
return
154123

155-
# Get the span kind from the span.kind property or the attributes
156-
span_kind = span.kind.name if hasattr(span, "kind") else (
157-
span.attributes.get(semconv.SpanAttributes.AGENTOPS_SPAN_KIND, "unknown") if span.attributes else "unknown"
158-
)
159-
160-
# Special handling for session spans
161-
if span_kind == semconv.SpanKind.SESSION:
162-
trace_id = span.context.trace_id
163-
# Convert trace_id to hex string if it's not already
164-
if isinstance(trace_id, int):
165-
session_url = f"{self.app_url}/drilldown?session_id={trace_id_to_uuid(trace_id)}"
166-
logger.info(
167-
colored(
168-
f"\x1b[34mSession Replay: {session_url}\x1b[0m",
169-
"blue",
170-
)
171-
)
172-
else:
173-
# Print basic information for other span kinds
174-
# For native OpenTelemetry SpanKind values (INTERNAL, CLIENT, CONSUMER, etc.),
175-
# we'll see the actual kind rather than "unknown"
176-
logger.debug(f"Ended span: {span.name} (kind: {span_kind})")
124+
if self._root_span_id and (span.context.span_id is self._root_span_id):
125+
logger.debug(f"[agentops.InternalSpanProcessor] Ending root span: {span.name}")
126+
log_trace_url(span)
177127

178128
def shutdown(self) -> None:
179129
"""Shutdown the processor."""
180-
pass
130+
self._root_span_id = None
181131

182132
def force_flush(self, timeout_millis: int = 30000) -> bool:
183133
"""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)