mseep-agentops 0.4.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentops/__init__.py +488 -0
- agentops/client/__init__.py +5 -0
- agentops/client/api/__init__.py +71 -0
- agentops/client/api/base.py +162 -0
- agentops/client/api/types.py +21 -0
- agentops/client/api/versions/__init__.py +10 -0
- agentops/client/api/versions/v3.py +65 -0
- agentops/client/api/versions/v4.py +104 -0
- agentops/client/client.py +211 -0
- agentops/client/http/__init__.py +0 -0
- agentops/client/http/http_adapter.py +116 -0
- agentops/client/http/http_client.py +215 -0
- agentops/config.py +268 -0
- agentops/enums.py +36 -0
- agentops/exceptions.py +38 -0
- agentops/helpers/__init__.py +44 -0
- agentops/helpers/dashboard.py +54 -0
- agentops/helpers/deprecation.py +50 -0
- agentops/helpers/env.py +52 -0
- agentops/helpers/serialization.py +137 -0
- agentops/helpers/system.py +178 -0
- agentops/helpers/time.py +11 -0
- agentops/helpers/version.py +36 -0
- agentops/instrumentation/__init__.py +598 -0
- agentops/instrumentation/common/__init__.py +82 -0
- agentops/instrumentation/common/attributes.py +278 -0
- agentops/instrumentation/common/instrumentor.py +147 -0
- agentops/instrumentation/common/metrics.py +100 -0
- agentops/instrumentation/common/objects.py +26 -0
- agentops/instrumentation/common/span_management.py +176 -0
- agentops/instrumentation/common/streaming.py +218 -0
- agentops/instrumentation/common/token_counting.py +177 -0
- agentops/instrumentation/common/version.py +71 -0
- agentops/instrumentation/common/wrappers.py +235 -0
- agentops/legacy/__init__.py +277 -0
- agentops/legacy/event.py +156 -0
- agentops/logging/__init__.py +4 -0
- agentops/logging/config.py +86 -0
- agentops/logging/formatters.py +34 -0
- agentops/logging/instrument_logging.py +91 -0
- agentops/sdk/__init__.py +27 -0
- agentops/sdk/attributes.py +151 -0
- agentops/sdk/core.py +607 -0
- agentops/sdk/decorators/__init__.py +51 -0
- agentops/sdk/decorators/factory.py +486 -0
- agentops/sdk/decorators/utility.py +216 -0
- agentops/sdk/exporters.py +87 -0
- agentops/sdk/processors.py +71 -0
- agentops/sdk/types.py +21 -0
- agentops/semconv/__init__.py +36 -0
- agentops/semconv/agent.py +29 -0
- agentops/semconv/core.py +19 -0
- agentops/semconv/enum.py +11 -0
- agentops/semconv/instrumentation.py +13 -0
- agentops/semconv/langchain.py +63 -0
- agentops/semconv/message.py +61 -0
- agentops/semconv/meters.py +24 -0
- agentops/semconv/resource.py +52 -0
- agentops/semconv/span_attributes.py +118 -0
- agentops/semconv/span_kinds.py +50 -0
- agentops/semconv/status.py +11 -0
- agentops/semconv/tool.py +15 -0
- agentops/semconv/workflow.py +69 -0
- agentops/validation.py +357 -0
- mseep_agentops-0.4.18.dist-info/METADATA +49 -0
- mseep_agentops-0.4.18.dist-info/RECORD +94 -0
- mseep_agentops-0.4.18.dist-info/WHEEL +5 -0
- mseep_agentops-0.4.18.dist-info/licenses/LICENSE +21 -0
- mseep_agentops-0.4.18.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +10 -0
- tests/unit/__init__.py +0 -0
- tests/unit/client/__init__.py +1 -0
- tests/unit/client/test_http_adapter.py +221 -0
- tests/unit/client/test_http_client.py +206 -0
- tests/unit/conftest.py +54 -0
- tests/unit/sdk/__init__.py +1 -0
- tests/unit/sdk/instrumentation_tester.py +207 -0
- tests/unit/sdk/test_attributes.py +392 -0
- tests/unit/sdk/test_concurrent_instrumentation.py +468 -0
- tests/unit/sdk/test_decorators.py +763 -0
- tests/unit/sdk/test_exporters.py +241 -0
- tests/unit/sdk/test_factory.py +1188 -0
- tests/unit/sdk/test_internal_span_processor.py +397 -0
- tests/unit/sdk/test_resource_attributes.py +35 -0
- tests/unit/test_config.py +82 -0
- tests/unit/test_context_manager.py +777 -0
- tests/unit/test_events.py +27 -0
- tests/unit/test_host_env.py +54 -0
- tests/unit/test_init_py.py +501 -0
- tests/unit/test_serialization.py +433 -0
- tests/unit/test_session.py +676 -0
- tests/unit/test_user_agent.py +34 -0
- tests/unit/test_validation.py +405 -0
@@ -0,0 +1,176 @@
|
|
1
|
+
"""Common span management utilities for AgentOps instrumentation.
|
2
|
+
|
3
|
+
This module provides utilities for creating and managing spans with
|
4
|
+
consistent attributes and error handling.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import time
|
8
|
+
from contextlib import contextmanager
|
9
|
+
from typing import Optional, Dict, Any, Callable, Tuple
|
10
|
+
from functools import wraps
|
11
|
+
|
12
|
+
from opentelemetry.trace import Tracer, Span, SpanKind, Status, StatusCode, get_current_span
|
13
|
+
from opentelemetry.sdk.resources import SERVICE_NAME, TELEMETRY_SDK_NAME, DEPLOYMENT_ENVIRONMENT
|
14
|
+
from opentelemetry import context as context_api
|
15
|
+
|
16
|
+
from agentops.logging import logger
|
17
|
+
from agentops.semconv import CoreAttributes
|
18
|
+
|
19
|
+
|
20
|
+
class SpanAttributeManager:
|
21
|
+
"""Manages common span attributes across instrumentations."""
|
22
|
+
|
23
|
+
def __init__(self, service_name: str = "agentops", deployment_environment: str = "production"):
|
24
|
+
self.service_name = service_name
|
25
|
+
self.deployment_environment = deployment_environment
|
26
|
+
|
27
|
+
def set_common_attributes(self, span: Span):
|
28
|
+
"""Set common attributes on a span."""
|
29
|
+
span.set_attribute(TELEMETRY_SDK_NAME, "agentops")
|
30
|
+
span.set_attribute(SERVICE_NAME, self.service_name)
|
31
|
+
span.set_attribute(DEPLOYMENT_ENVIRONMENT, self.deployment_environment)
|
32
|
+
|
33
|
+
def set_config_tags(self, span: Span):
|
34
|
+
"""Set tags from AgentOps config on a span."""
|
35
|
+
# Import locally to avoid circular dependency
|
36
|
+
from agentops import get_client
|
37
|
+
|
38
|
+
client = get_client()
|
39
|
+
if client and client.config.default_tags and len(client.config.default_tags) > 0:
|
40
|
+
tag_list = list(client.config.default_tags)
|
41
|
+
span.set_attribute(CoreAttributes.TAGS, tag_list)
|
42
|
+
|
43
|
+
|
44
|
+
@contextmanager
|
45
|
+
def create_span(
|
46
|
+
tracer: Tracer,
|
47
|
+
name: str,
|
48
|
+
kind: SpanKind = SpanKind.CLIENT,
|
49
|
+
attributes: Optional[Dict[str, Any]] = None,
|
50
|
+
set_common_attributes: bool = True,
|
51
|
+
attribute_manager: Optional[SpanAttributeManager] = None,
|
52
|
+
):
|
53
|
+
"""Context manager for creating spans with consistent error handling.
|
54
|
+
|
55
|
+
Args:
|
56
|
+
tracer: The tracer to use for creating the span
|
57
|
+
name: The name of the span
|
58
|
+
kind: The kind of span to create
|
59
|
+
attributes: Initial attributes to set on the span
|
60
|
+
set_common_attributes: Whether to set common attributes
|
61
|
+
attribute_manager: Optional attribute manager for setting common attributes
|
62
|
+
|
63
|
+
Yields:
|
64
|
+
The created span
|
65
|
+
"""
|
66
|
+
with tracer.start_as_current_span(name, kind=kind, attributes=attributes or {}) as span:
|
67
|
+
try:
|
68
|
+
if set_common_attributes and attribute_manager:
|
69
|
+
attribute_manager.set_common_attributes(span)
|
70
|
+
yield span
|
71
|
+
span.set_status(Status(StatusCode.OK))
|
72
|
+
except Exception as e:
|
73
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
74
|
+
span.record_exception(e)
|
75
|
+
logger.error(f"Error in span {name}: {e}")
|
76
|
+
raise
|
77
|
+
|
78
|
+
|
79
|
+
def timed_span(tracer: Tracer, name: str, record_duration: Optional[Callable[[float], None]] = None, **span_kwargs):
|
80
|
+
"""Decorator for creating timed spans around functions.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
tracer: The tracer to use
|
84
|
+
name: The name of the span
|
85
|
+
record_duration: Optional callback to record duration
|
86
|
+
**span_kwargs: Additional arguments for span creation
|
87
|
+
"""
|
88
|
+
|
89
|
+
def decorator(func):
|
90
|
+
@wraps(func)
|
91
|
+
def wrapper(*args, **kwargs):
|
92
|
+
start_time = time.time()
|
93
|
+
with create_span(tracer, name, **span_kwargs):
|
94
|
+
result = func(*args, **kwargs)
|
95
|
+
if record_duration:
|
96
|
+
duration = time.time() - start_time
|
97
|
+
record_duration(duration)
|
98
|
+
return result
|
99
|
+
|
100
|
+
return wrapper
|
101
|
+
|
102
|
+
return decorator
|
103
|
+
|
104
|
+
|
105
|
+
class StreamingSpanManager:
|
106
|
+
"""Manages spans for streaming operations."""
|
107
|
+
|
108
|
+
def __init__(self, tracer: Tracer):
|
109
|
+
self.tracer = tracer
|
110
|
+
self._active_spans: Dict[Any, Span] = {}
|
111
|
+
|
112
|
+
def start_streaming_span(self, stream_id: Any, name: str, **span_kwargs) -> Span:
|
113
|
+
"""Start a span for a streaming operation."""
|
114
|
+
span = self.tracer.start_span(name, **span_kwargs)
|
115
|
+
self._active_spans[stream_id] = span
|
116
|
+
return span
|
117
|
+
|
118
|
+
def get_streaming_span(self, stream_id: Any) -> Optional[Span]:
|
119
|
+
"""Get an active streaming span."""
|
120
|
+
return self._active_spans.get(stream_id)
|
121
|
+
|
122
|
+
def end_streaming_span(self, stream_id: Any, status: Optional[Status] = None):
|
123
|
+
"""End a streaming span."""
|
124
|
+
span = self._active_spans.pop(stream_id, None)
|
125
|
+
if span:
|
126
|
+
if status:
|
127
|
+
span.set_status(status)
|
128
|
+
else:
|
129
|
+
span.set_status(Status(StatusCode.OK))
|
130
|
+
span.end()
|
131
|
+
|
132
|
+
|
133
|
+
def extract_parent_context(parent_span: Optional[Span] = None) -> Any:
|
134
|
+
"""Extract parent context for span creation.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
parent_span: Optional parent span to use
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
Context to use as parent
|
141
|
+
"""
|
142
|
+
if parent_span:
|
143
|
+
from opentelemetry.trace import set_span_in_context
|
144
|
+
|
145
|
+
return set_span_in_context(parent_span)
|
146
|
+
return context_api.get_current()
|
147
|
+
|
148
|
+
|
149
|
+
def safe_set_attribute(span: Span, key: str, value: Any, max_length: int = 1000):
|
150
|
+
"""Safely set an attribute on a span, handling None values and truncating long strings."""
|
151
|
+
if value is None:
|
152
|
+
return
|
153
|
+
|
154
|
+
if isinstance(value, str) and len(value) > max_length:
|
155
|
+
value = value[: max_length - 3] + "..."
|
156
|
+
|
157
|
+
try:
|
158
|
+
span.set_attribute(key, value)
|
159
|
+
except Exception as e:
|
160
|
+
logger.debug(f"Failed to set span attribute {key}: {e}")
|
161
|
+
|
162
|
+
|
163
|
+
def get_span_context_info(span: Optional[Span] = None) -> Tuple[str, str]:
|
164
|
+
"""Get trace and span IDs from a span for debugging.
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
Tuple of (trace_id, span_id) as strings
|
168
|
+
"""
|
169
|
+
if not span:
|
170
|
+
span = get_current_span()
|
171
|
+
|
172
|
+
span_context = span.get_span_context()
|
173
|
+
trace_id = format(span_context.trace_id, "032x") if span_context.trace_id else "unknown"
|
174
|
+
span_id = format(span_context.span_id, "016x") if span_context.span_id else "unknown"
|
175
|
+
|
176
|
+
return trace_id, span_id
|
@@ -0,0 +1,218 @@
|
|
1
|
+
"""Common streaming utilities for handling streaming responses.
|
2
|
+
|
3
|
+
This module provides utilities for instrumenting streaming API responses
|
4
|
+
in a consistent way across different providers.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import Optional, Any, Dict, Callable
|
8
|
+
from abc import ABC
|
9
|
+
import time
|
10
|
+
|
11
|
+
from opentelemetry.trace import Tracer, Span, Status, StatusCode
|
12
|
+
|
13
|
+
from agentops.logging import logger
|
14
|
+
from agentops.instrumentation.common.span_management import safe_set_attribute
|
15
|
+
from agentops.instrumentation.common.token_counting import TokenUsage, TokenUsageExtractor
|
16
|
+
|
17
|
+
|
18
|
+
class BaseStreamWrapper(ABC):
|
19
|
+
"""Base class for wrapping streaming responses."""
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
stream: Any,
|
24
|
+
span: Span,
|
25
|
+
extract_chunk_content: Callable[[Any], Optional[str]],
|
26
|
+
extract_chunk_attributes: Optional[Callable[[Any], Dict[str, Any]]] = None,
|
27
|
+
):
|
28
|
+
self.stream = stream
|
29
|
+
self.span = span
|
30
|
+
self.extract_chunk_content = extract_chunk_content
|
31
|
+
self.extract_chunk_attributes = extract_chunk_attributes or (lambda x: {})
|
32
|
+
|
33
|
+
self.start_time = time.time()
|
34
|
+
self.first_token_time: Optional[float] = None
|
35
|
+
self.chunks_received = 0
|
36
|
+
self.accumulated_content = []
|
37
|
+
self.token_usage = TokenUsage()
|
38
|
+
|
39
|
+
def _process_chunk(self, chunk: Any):
|
40
|
+
"""Process a single chunk from the stream."""
|
41
|
+
# Record time to first token
|
42
|
+
if self.first_token_time is None:
|
43
|
+
self.first_token_time = time.time()
|
44
|
+
time_to_first_token = self.first_token_time - self.start_time
|
45
|
+
safe_set_attribute(self.span, "streaming.time_to_first_token", time_to_first_token)
|
46
|
+
|
47
|
+
self.chunks_received += 1
|
48
|
+
|
49
|
+
# Extract content from chunk
|
50
|
+
content = self.extract_chunk_content(chunk)
|
51
|
+
if content:
|
52
|
+
self.accumulated_content.append(content)
|
53
|
+
|
54
|
+
# Extract and set additional attributes
|
55
|
+
attributes = self.extract_chunk_attributes(chunk)
|
56
|
+
for key, value in attributes.items():
|
57
|
+
safe_set_attribute(self.span, key, value)
|
58
|
+
|
59
|
+
# Try to extract token usage if available
|
60
|
+
if hasattr(chunk, "usage") or hasattr(chunk, "usage_metadata"):
|
61
|
+
chunk_usage = TokenUsageExtractor.extract_from_response(chunk)
|
62
|
+
# Accumulate token counts
|
63
|
+
if chunk_usage.prompt_tokens:
|
64
|
+
self.token_usage.prompt_tokens = chunk_usage.prompt_tokens
|
65
|
+
if chunk_usage.completion_tokens:
|
66
|
+
self.token_usage.completion_tokens = (
|
67
|
+
self.token_usage.completion_tokens or 0
|
68
|
+
) + chunk_usage.completion_tokens
|
69
|
+
|
70
|
+
def _finalize(self):
|
71
|
+
"""Finalize the stream processing."""
|
72
|
+
try:
|
73
|
+
# Set final content
|
74
|
+
final_content = "".join(self.accumulated_content)
|
75
|
+
safe_set_attribute(self.span, "streaming.final_content", final_content)
|
76
|
+
safe_set_attribute(self.span, "streaming.chunk_count", self.chunks_received)
|
77
|
+
|
78
|
+
# Set timing metrics
|
79
|
+
total_time = time.time() - self.start_time
|
80
|
+
safe_set_attribute(self.span, "streaming.total_duration", total_time)
|
81
|
+
|
82
|
+
if self.first_token_time:
|
83
|
+
generation_time = time.time() - self.first_token_time
|
84
|
+
safe_set_attribute(self.span, "streaming.generation_duration", generation_time)
|
85
|
+
|
86
|
+
# Set token usage
|
87
|
+
for attr_name, value in self.token_usage.to_attributes().items():
|
88
|
+
safe_set_attribute(self.span, attr_name, value)
|
89
|
+
|
90
|
+
self.span.set_status(Status(StatusCode.OK))
|
91
|
+
except Exception as e:
|
92
|
+
logger.error(f"Error finalizing stream: {e}")
|
93
|
+
self.span.set_status(Status(StatusCode.ERROR, str(e)))
|
94
|
+
self.span.record_exception(e)
|
95
|
+
finally:
|
96
|
+
self.span.end()
|
97
|
+
|
98
|
+
|
99
|
+
class SyncStreamWrapper(BaseStreamWrapper):
|
100
|
+
"""Wrapper for synchronous streaming responses."""
|
101
|
+
|
102
|
+
def __iter__(self):
|
103
|
+
try:
|
104
|
+
for chunk in self.stream:
|
105
|
+
self._process_chunk(chunk)
|
106
|
+
yield chunk
|
107
|
+
except Exception as e:
|
108
|
+
self.span.set_status(Status(StatusCode.ERROR, str(e)))
|
109
|
+
self.span.record_exception(e)
|
110
|
+
raise
|
111
|
+
finally:
|
112
|
+
self._finalize()
|
113
|
+
|
114
|
+
|
115
|
+
class AsyncStreamWrapper(BaseStreamWrapper):
|
116
|
+
"""Wrapper for asynchronous streaming responses."""
|
117
|
+
|
118
|
+
async def __aiter__(self):
|
119
|
+
try:
|
120
|
+
async for chunk in self.stream:
|
121
|
+
self._process_chunk(chunk)
|
122
|
+
yield chunk
|
123
|
+
except Exception as e:
|
124
|
+
self.span.set_status(Status(StatusCode.ERROR, str(e)))
|
125
|
+
self.span.record_exception(e)
|
126
|
+
raise
|
127
|
+
finally:
|
128
|
+
self._finalize()
|
129
|
+
|
130
|
+
|
131
|
+
def create_stream_wrapper_factory(
|
132
|
+
tracer: Tracer,
|
133
|
+
span_name: str,
|
134
|
+
extract_chunk_content: Callable[[Any], Optional[str]],
|
135
|
+
extract_chunk_attributes: Optional[Callable[[Any], Dict[str, Any]]] = None,
|
136
|
+
initial_attributes: Optional[Dict[str, Any]] = None,
|
137
|
+
) -> Callable:
|
138
|
+
"""Create a factory function for wrapping streaming methods.
|
139
|
+
|
140
|
+
Args:
|
141
|
+
tracer: The tracer to use for creating spans
|
142
|
+
span_name: Name for the streaming span
|
143
|
+
extract_chunk_content: Function to extract content from chunks
|
144
|
+
extract_chunk_attributes: Optional function to extract attributes from chunks
|
145
|
+
initial_attributes: Initial attributes to set on the span
|
146
|
+
|
147
|
+
Returns:
|
148
|
+
A wrapper function suitable for use with wrapt
|
149
|
+
"""
|
150
|
+
|
151
|
+
def wrapper(wrapped, instance, args, kwargs):
|
152
|
+
# Start the span
|
153
|
+
span = tracer.start_span(span_name)
|
154
|
+
|
155
|
+
# Set initial attributes
|
156
|
+
if initial_attributes:
|
157
|
+
for key, value in initial_attributes.items():
|
158
|
+
safe_set_attribute(span, key, value)
|
159
|
+
|
160
|
+
try:
|
161
|
+
# Call the wrapped method
|
162
|
+
stream = wrapped(*args, **kwargs)
|
163
|
+
|
164
|
+
# Determine if it's async or sync
|
165
|
+
if hasattr(stream, "__aiter__"):
|
166
|
+
return AsyncStreamWrapper(stream, span, extract_chunk_content, extract_chunk_attributes)
|
167
|
+
else:
|
168
|
+
return SyncStreamWrapper(stream, span, extract_chunk_content, extract_chunk_attributes)
|
169
|
+
except Exception as e:
|
170
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
171
|
+
span.record_exception(e)
|
172
|
+
span.end()
|
173
|
+
raise
|
174
|
+
|
175
|
+
return wrapper
|
176
|
+
|
177
|
+
|
178
|
+
class StreamingResponseHandler:
|
179
|
+
"""Handles common patterns for streaming responses."""
|
180
|
+
|
181
|
+
@staticmethod
|
182
|
+
def extract_openai_chunk_content(chunk: Any) -> Optional[str]:
|
183
|
+
"""Extract content from OpenAI-style streaming chunks."""
|
184
|
+
if hasattr(chunk, "choices") and chunk.choices:
|
185
|
+
delta = getattr(chunk.choices[0], "delta", None)
|
186
|
+
if delta and hasattr(delta, "content"):
|
187
|
+
return delta.content
|
188
|
+
return None
|
189
|
+
|
190
|
+
@staticmethod
|
191
|
+
def extract_anthropic_chunk_content(chunk: Any) -> Optional[str]:
|
192
|
+
"""Extract content from Anthropic-style streaming chunks."""
|
193
|
+
if hasattr(chunk, "type"):
|
194
|
+
if chunk.type == "content_block_delta":
|
195
|
+
if hasattr(chunk, "delta") and hasattr(chunk.delta, "text"):
|
196
|
+
return chunk.delta.text
|
197
|
+
elif chunk.type == "message_delta":
|
198
|
+
if hasattr(chunk, "delta") and hasattr(chunk.delta, "content"):
|
199
|
+
return chunk.delta.content
|
200
|
+
return None
|
201
|
+
|
202
|
+
@staticmethod
|
203
|
+
def extract_generic_chunk_content(chunk: Any) -> Optional[str]:
|
204
|
+
"""Extract content from generic streaming chunks."""
|
205
|
+
# Try common patterns
|
206
|
+
if hasattr(chunk, "content"):
|
207
|
+
return str(chunk.content)
|
208
|
+
elif hasattr(chunk, "text"):
|
209
|
+
return str(chunk.text)
|
210
|
+
elif hasattr(chunk, "delta"):
|
211
|
+
delta = chunk.delta
|
212
|
+
if hasattr(delta, "content"):
|
213
|
+
return str(delta.content)
|
214
|
+
elif hasattr(delta, "text"):
|
215
|
+
return str(delta.text)
|
216
|
+
elif isinstance(chunk, str):
|
217
|
+
return chunk
|
218
|
+
return None
|
@@ -0,0 +1,177 @@
|
|
1
|
+
"""Common token counting and usage extraction utilities.
|
2
|
+
|
3
|
+
This module provides utilities for extracting and recording token usage
|
4
|
+
information from various response formats.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import Dict, Any, Optional
|
8
|
+
from dataclasses import dataclass
|
9
|
+
|
10
|
+
from agentops.logging import logger
|
11
|
+
from agentops.semconv import SpanAttributes
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class TokenUsage:
|
16
|
+
"""Represents token usage information."""
|
17
|
+
|
18
|
+
prompt_tokens: Optional[int] = None
|
19
|
+
completion_tokens: Optional[int] = None
|
20
|
+
total_tokens: Optional[int] = None
|
21
|
+
cached_prompt_tokens: Optional[int] = None
|
22
|
+
cached_read_tokens: Optional[int] = None
|
23
|
+
reasoning_tokens: Optional[int] = None
|
24
|
+
|
25
|
+
def to_attributes(self) -> Dict[str, int]:
|
26
|
+
"""Convert to span attributes dictionary.
|
27
|
+
|
28
|
+
Only metrics greater than zero are included so that non‑LLM spans do
|
29
|
+
not contain empty token usage fields.
|
30
|
+
"""
|
31
|
+
attributes = {}
|
32
|
+
|
33
|
+
if self.prompt_tokens:
|
34
|
+
attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] = self.prompt_tokens
|
35
|
+
|
36
|
+
if self.completion_tokens:
|
37
|
+
attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] = self.completion_tokens
|
38
|
+
|
39
|
+
if self.total_tokens:
|
40
|
+
attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] = self.total_tokens
|
41
|
+
|
42
|
+
if self.cached_prompt_tokens:
|
43
|
+
attributes[SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS] = self.cached_prompt_tokens
|
44
|
+
|
45
|
+
if self.cached_read_tokens:
|
46
|
+
attributes[SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS] = self.cached_read_tokens
|
47
|
+
|
48
|
+
if self.reasoning_tokens:
|
49
|
+
attributes[SpanAttributes.LLM_USAGE_REASONING_TOKENS] = self.reasoning_tokens
|
50
|
+
|
51
|
+
return attributes
|
52
|
+
|
53
|
+
|
54
|
+
class TokenUsageExtractor:
|
55
|
+
"""Extracts token usage from various response formats."""
|
56
|
+
|
57
|
+
@staticmethod
|
58
|
+
def extract_from_response(response: Any) -> TokenUsage:
|
59
|
+
"""Extract token usage from a generic response object.
|
60
|
+
|
61
|
+
Handles various response formats from different providers.
|
62
|
+
"""
|
63
|
+
usage = TokenUsage()
|
64
|
+
|
65
|
+
# Try direct usage attribute
|
66
|
+
if hasattr(response, "usage"):
|
67
|
+
usage_data = response.usage
|
68
|
+
usage = TokenUsageExtractor._extract_from_usage_object(usage_data)
|
69
|
+
|
70
|
+
# Try usage_metadata (Anthropic style)
|
71
|
+
elif hasattr(response, "usage_metadata"):
|
72
|
+
usage_data = response.usage_metadata
|
73
|
+
usage = TokenUsageExtractor._extract_from_usage_object(usage_data)
|
74
|
+
|
75
|
+
# Try token_usage attribute (CrewAI style)
|
76
|
+
elif hasattr(response, "token_usage"):
|
77
|
+
usage = TokenUsageExtractor._extract_from_crewai_format(response.token_usage)
|
78
|
+
|
79
|
+
# Try direct attributes on response
|
80
|
+
elif hasattr(response, "prompt_tokens") or hasattr(response, "completion_tokens"):
|
81
|
+
usage = TokenUsageExtractor._extract_from_attributes(response)
|
82
|
+
|
83
|
+
return usage
|
84
|
+
|
85
|
+
@staticmethod
|
86
|
+
def _extract_from_usage_object(usage_data: Any) -> TokenUsage:
|
87
|
+
"""Extract from a usage object with standard attributes."""
|
88
|
+
if not usage_data:
|
89
|
+
return TokenUsage()
|
90
|
+
|
91
|
+
return TokenUsage(
|
92
|
+
prompt_tokens=getattr(usage_data, "prompt_tokens", None),
|
93
|
+
completion_tokens=getattr(usage_data, "completion_tokens", None),
|
94
|
+
total_tokens=getattr(usage_data, "total_tokens", None),
|
95
|
+
cached_prompt_tokens=getattr(usage_data, "cached_prompt_tokens", None),
|
96
|
+
cached_read_tokens=getattr(usage_data, "cache_read_input_tokens", None),
|
97
|
+
reasoning_tokens=getattr(usage_data, "reasoning_tokens", None),
|
98
|
+
)
|
99
|
+
|
100
|
+
@staticmethod
|
101
|
+
def _extract_from_crewai_format(token_usage_str: str) -> TokenUsage:
|
102
|
+
"""Extract from CrewAI's string format (e.g., 'prompt_tokens=100 completion_tokens=50')."""
|
103
|
+
usage = TokenUsage()
|
104
|
+
|
105
|
+
try:
|
106
|
+
metrics = {}
|
107
|
+
for item in str(token_usage_str).split():
|
108
|
+
if "=" in item:
|
109
|
+
key, value = item.split("=")
|
110
|
+
try:
|
111
|
+
metrics[key] = int(value)
|
112
|
+
except ValueError:
|
113
|
+
pass
|
114
|
+
|
115
|
+
usage.prompt_tokens = metrics.get("prompt_tokens")
|
116
|
+
usage.completion_tokens = metrics.get("completion_tokens")
|
117
|
+
usage.total_tokens = metrics.get("total_tokens")
|
118
|
+
usage.cached_prompt_tokens = metrics.get("cached_prompt_tokens")
|
119
|
+
|
120
|
+
except Exception as e:
|
121
|
+
logger.debug(f"Failed to parse CrewAI token usage: {e}")
|
122
|
+
|
123
|
+
return usage
|
124
|
+
|
125
|
+
@staticmethod
|
126
|
+
def _extract_from_attributes(response: Any) -> TokenUsage:
|
127
|
+
"""Extract from direct attributes on the response."""
|
128
|
+
return TokenUsage(
|
129
|
+
prompt_tokens=getattr(response, "prompt_tokens", None),
|
130
|
+
completion_tokens=getattr(response, "completion_tokens", None),
|
131
|
+
total_tokens=getattr(response, "total_tokens", None),
|
132
|
+
)
|
133
|
+
|
134
|
+
|
135
|
+
def calculate_token_efficiency(usage: TokenUsage) -> Optional[float]:
|
136
|
+
"""Calculate token efficiency ratio (completion/prompt).
|
137
|
+
|
138
|
+
Returns:
|
139
|
+
Efficiency ratio or None if cannot be calculated
|
140
|
+
"""
|
141
|
+
if usage.prompt_tokens and usage.completion_tokens and usage.prompt_tokens > 0:
|
142
|
+
return usage.completion_tokens / usage.prompt_tokens
|
143
|
+
return None
|
144
|
+
|
145
|
+
|
146
|
+
def calculate_cache_efficiency(usage: TokenUsage) -> Optional[float]:
|
147
|
+
"""Calculate cache efficiency ratio (cached/total prompt).
|
148
|
+
|
149
|
+
Returns:
|
150
|
+
Cache ratio or None if cannot be calculated
|
151
|
+
"""
|
152
|
+
if usage.prompt_tokens and usage.cached_prompt_tokens and usage.prompt_tokens > 0:
|
153
|
+
return usage.cached_prompt_tokens / usage.prompt_tokens
|
154
|
+
return None
|
155
|
+
|
156
|
+
|
157
|
+
def set_token_usage_attributes(span: Any, response: Any):
|
158
|
+
"""Extract and set token usage attributes on a span.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
span: The span to set attributes on
|
162
|
+
response: The response object to extract usage from
|
163
|
+
"""
|
164
|
+
usage = TokenUsageExtractor.extract_from_response(response)
|
165
|
+
|
166
|
+
# Set basic token attributes
|
167
|
+
for attr_name, value in usage.to_attributes().items():
|
168
|
+
span.set_attribute(attr_name, value)
|
169
|
+
|
170
|
+
# Calculate and set efficiency metrics
|
171
|
+
efficiency = calculate_token_efficiency(usage)
|
172
|
+
if efficiency is not None:
|
173
|
+
span.set_attribute("llm.token_efficiency", f"{efficiency:.4f}")
|
174
|
+
|
175
|
+
cache_efficiency = calculate_cache_efficiency(usage)
|
176
|
+
if cache_efficiency is not None:
|
177
|
+
span.set_attribute("llm.cache_efficiency", f"{cache_efficiency:.4f}")
|
@@ -0,0 +1,71 @@
|
|
1
|
+
"""Version utilities for AgentOps instrumentation.
|
2
|
+
|
3
|
+
This module provides common functionality for retrieving and managing
|
4
|
+
library versions across all instrumentation modules.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
from typing import Optional
|
9
|
+
|
10
|
+
logger = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
def get_library_version(package_name: str, default_version: str = "unknown") -> str:
|
14
|
+
"""Get the version of a library package.
|
15
|
+
|
16
|
+
Attempts to retrieve the installed version of a package using importlib.metadata.
|
17
|
+
Falls back to the default version if the version cannot be determined.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
package_name: The name of the package to get the version for (as used in pip/importlib.metadata)
|
21
|
+
default_version: The default version to return if the package version cannot be found
|
22
|
+
|
23
|
+
Returns:
|
24
|
+
The version string of the package or the default version
|
25
|
+
|
26
|
+
Examples:
|
27
|
+
>>> get_library_version("openai")
|
28
|
+
"1.0.0"
|
29
|
+
|
30
|
+
>>> get_library_version("nonexistent-package")
|
31
|
+
"unknown"
|
32
|
+
|
33
|
+
>>> get_library_version("ibm-watsonx-ai", "1.3.11")
|
34
|
+
"1.3.11" # If not found
|
35
|
+
"""
|
36
|
+
try:
|
37
|
+
from importlib.metadata import version
|
38
|
+
|
39
|
+
return version(package_name)
|
40
|
+
except (ImportError, Exception) as e:
|
41
|
+
logger.debug(f"Could not find {package_name} version: {e}")
|
42
|
+
return default_version
|
43
|
+
|
44
|
+
|
45
|
+
class LibraryInfo:
|
46
|
+
"""Container for library information used in instrumentation.
|
47
|
+
|
48
|
+
This class provides a standardized way to store and access library
|
49
|
+
information (name and version) across all instrumentors.
|
50
|
+
|
51
|
+
Attributes:
|
52
|
+
name: The library name used for identification
|
53
|
+
version: The library version string
|
54
|
+
package_name: The package name used in pip/importlib.metadata (optional)
|
55
|
+
"""
|
56
|
+
|
57
|
+
def __init__(self, name: str, package_name: Optional[str] = None, default_version: str = "unknown"):
|
58
|
+
"""Initialize library information.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
name: The library name used for identification
|
62
|
+
package_name: The package name used in pip/importlib.metadata.
|
63
|
+
If not provided, uses the library name.
|
64
|
+
default_version: Default version if package version cannot be determined
|
65
|
+
"""
|
66
|
+
self.name = name
|
67
|
+
self.package_name = package_name or name
|
68
|
+
self.version = get_library_version(self.package_name, default_version)
|
69
|
+
|
70
|
+
def __repr__(self) -> str:
|
71
|
+
return f"LibraryInfo(name={self.name!r}, version={self.version!r})"
|