asi-telemetry 0.2.0__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.
- asi_telemetry/__init__.py +168 -0
- asi_telemetry/instrumentation.py +114 -0
- asi_telemetry/metrics.py +164 -0
- asi_telemetry/models.py +30 -0
- asi_telemetry/tracer.py +250 -0
- asi_telemetry-0.2.0.dist-info/METADATA +110 -0
- asi_telemetry-0.2.0.dist-info/RECORD +10 -0
- asi_telemetry-0.2.0.dist-info/WHEEL +5 -0
- asi_telemetry-0.2.0.dist-info/licenses/LICENSE +21 -0
- asi_telemetry-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Callable, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from .metrics import MetricsTracker
|
|
5
|
+
from .tracer import (
|
|
6
|
+
traceable,
|
|
7
|
+
create_trace_context,
|
|
8
|
+
set_agent_info,
|
|
9
|
+
set_api_key,
|
|
10
|
+
)
|
|
11
|
+
from .instrumentation import (
|
|
12
|
+
instrument_gemini,
|
|
13
|
+
instrument_openai,
|
|
14
|
+
instrument_client,
|
|
15
|
+
instrument_llm_provider,
|
|
16
|
+
)
|
|
17
|
+
from .models import calculate_cost
|
|
18
|
+
|
|
19
|
+
__version__ = "0.2.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ASI_Telemetry:
|
|
23
|
+
"""
|
|
24
|
+
Unified ASI Telemetry client.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
from asi_telemetry import ASI_Telemetry
|
|
28
|
+
|
|
29
|
+
observe = ASI_Telemetry(
|
|
30
|
+
api_key="asi_live_...",
|
|
31
|
+
agent_name="assistant",
|
|
32
|
+
agent_address=agent.address,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@observe.track("handle_message")
|
|
36
|
+
@observe.traceable(name="handle_message", run_type="tool")
|
|
37
|
+
async def handle_message(ctx, sender, msg):
|
|
38
|
+
...
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
DEFAULT_BACKEND_URL = "http://127.0.0.1:8080"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str = "",
|
|
46
|
+
agent_name: str = "assistant",
|
|
47
|
+
agent_address: str = "",
|
|
48
|
+
backend_url: Optional[str] = None,
|
|
49
|
+
):
|
|
50
|
+
self.api_key = (
|
|
51
|
+
api_key
|
|
52
|
+
or os.getenv("ASI_API_KEY")
|
|
53
|
+
or os.getenv("ASI_TELEMETRY_API_KEY")
|
|
54
|
+
or os.getenv("TELEMETRY_API_KEY")
|
|
55
|
+
or ""
|
|
56
|
+
)
|
|
57
|
+
self.backend_url = (
|
|
58
|
+
backend_url
|
|
59
|
+
or os.getenv("TELEMETRY_BACKEND_URL")
|
|
60
|
+
or self.DEFAULT_BACKEND_URL
|
|
61
|
+
)
|
|
62
|
+
self.agent_name = agent_name
|
|
63
|
+
self.agent_address = agent_address
|
|
64
|
+
|
|
65
|
+
# Initialize global environment & agent identities
|
|
66
|
+
if self.api_key:
|
|
67
|
+
set_api_key(self.api_key)
|
|
68
|
+
os.environ["ASI_API_KEY"] = self.api_key
|
|
69
|
+
if self.backend_url:
|
|
70
|
+
os.environ["TELEMETRY_BACKEND_URL"] = self.backend_url
|
|
71
|
+
os.environ["TELEMETRY_METRICS_URL"] = (
|
|
72
|
+
self.backend_url
|
|
73
|
+
if "/api/metrics" in self.backend_url or "/api/telemetry" in self.backend_url
|
|
74
|
+
else f"{self.backend_url.rstrip('/')}/api/metrics"
|
|
75
|
+
)
|
|
76
|
+
os.environ["TELEMETRY_TRACES_URL"] = (
|
|
77
|
+
self.backend_url
|
|
78
|
+
if "/api/llm/traces" in self.backend_url
|
|
79
|
+
else f"{self.backend_url.rstrip('/')}/api/llm/traces"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
set_agent_info(agent_name=self.agent_name, agent_address=self.agent_address)
|
|
83
|
+
|
|
84
|
+
# Internal metrics tracker
|
|
85
|
+
self.metrics = MetricsTracker(
|
|
86
|
+
agent_name=self.agent_name,
|
|
87
|
+
agent_address=self.agent_address,
|
|
88
|
+
api_key=self.api_key,
|
|
89
|
+
backend_url=os.environ.get("TELEMETRY_METRICS_URL", self.backend_url),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def track(self, tool_name: Optional[Any] = None) -> Callable:
|
|
93
|
+
"""Track function execution time, status, and agent performance metrics."""
|
|
94
|
+
return self.metrics.track(tool_name)
|
|
95
|
+
|
|
96
|
+
# Alias for track
|
|
97
|
+
tool_monitoring = track
|
|
98
|
+
|
|
99
|
+
def traceable(
|
|
100
|
+
self,
|
|
101
|
+
name: Optional[Any] = None,
|
|
102
|
+
run_type: str = "chain",
|
|
103
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
104
|
+
agent_address: Optional[str] = None,
|
|
105
|
+
) -> Callable:
|
|
106
|
+
"""Trace function execution, inputs, outputs, errors, latency, and tokens."""
|
|
107
|
+
return traceable(
|
|
108
|
+
name=name,
|
|
109
|
+
run_type=run_type,
|
|
110
|
+
metadata=metadata,
|
|
111
|
+
agent_address=agent_address or self.agent_address,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def create_trace_context(self) -> Dict[str, Optional[str]]:
|
|
115
|
+
"""Create a distributed tracing context dictionary for inter-agent messages."""
|
|
116
|
+
return create_trace_context()
|
|
117
|
+
|
|
118
|
+
def instrument_gemini(self, client: Any) -> Any:
|
|
119
|
+
"""Auto-instrument Google GenAI Gemini client."""
|
|
120
|
+
return instrument_gemini(client)
|
|
121
|
+
|
|
122
|
+
def instrument_openai(self, client: Any) -> Any:
|
|
123
|
+
"""Auto-instrument OpenAI client."""
|
|
124
|
+
return instrument_openai(client)
|
|
125
|
+
|
|
126
|
+
def instrument_client(self, client: Any, provider: str = "custom") -> Any:
|
|
127
|
+
"""Auto-instrument any LLM client."""
|
|
128
|
+
return instrument_client(client, provider)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Aliases for backward compatibility
|
|
132
|
+
ASITelemetry = ASI_Telemetry
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def init(
|
|
136
|
+
api_key: str = "",
|
|
137
|
+
agent_name: str = "assistant",
|
|
138
|
+
agent_address: str = "",
|
|
139
|
+
backend_url: Optional[str] = None,
|
|
140
|
+
) -> ASI_Telemetry:
|
|
141
|
+
"""Initialize the ASI Telemetry SDK with global client settings."""
|
|
142
|
+
return ASI_Telemetry(
|
|
143
|
+
api_key=api_key,
|
|
144
|
+
agent_name=agent_name,
|
|
145
|
+
agent_address=agent_address,
|
|
146
|
+
backend_url=backend_url,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = [
|
|
152
|
+
"__version__",
|
|
153
|
+
# Primary Client
|
|
154
|
+
"ASI_Telemetry",
|
|
155
|
+
"ASITelemetry",
|
|
156
|
+
# Core Entry Points
|
|
157
|
+
"init",
|
|
158
|
+
"traceable",
|
|
159
|
+
"create_trace_context",
|
|
160
|
+
"MetricsTracker",
|
|
161
|
+
# Instrumentations
|
|
162
|
+
"instrument_gemini",
|
|
163
|
+
"instrument_openai",
|
|
164
|
+
"instrument_client",
|
|
165
|
+
"instrument_llm_provider",
|
|
166
|
+
# Cost Utility
|
|
167
|
+
"calculate_cost",
|
|
168
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client auto-instrumentation for Google Gemini, OpenAI, and compatible LLM providers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from .models import calculate_cost
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save_token_usage(response: Any, span: dict, model: Optional[str] = None) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Extracts token usage metadata from Gemini or OpenAI/ASI responses
|
|
13
|
+
and writes token counts and estimated costs into the span.
|
|
14
|
+
"""
|
|
15
|
+
if not isinstance(span, dict):
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
# 1. Google Gemini style: response.usage_metadata
|
|
19
|
+
usage = getattr(response, "usage_metadata", None)
|
|
20
|
+
if usage:
|
|
21
|
+
input_tokens = getattr(usage, "prompt_token_count", 0) or 0
|
|
22
|
+
output_tokens = getattr(usage, "candidates_token_count", 0) or 0
|
|
23
|
+
total_tokens = getattr(usage, "total_token_count", 0) or (input_tokens + output_tokens)
|
|
24
|
+
resolved_model = model or span.get("metadata", {}).get("model", "gemini-3.8-flash")
|
|
25
|
+
else:
|
|
26
|
+
# 2. OpenAI / ASI style: response.usage
|
|
27
|
+
usage = getattr(response, "usage", None)
|
|
28
|
+
if not usage:
|
|
29
|
+
return # Not an LLM response with token usage
|
|
30
|
+
input_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
|
31
|
+
output_tokens = getattr(usage, "completion_tokens", 0) or 0
|
|
32
|
+
total_tokens = getattr(usage, "total_tokens", 0) or (input_tokens + output_tokens)
|
|
33
|
+
resolved_model = model or getattr(response, "model", span.get("metadata", {}).get("model", "asi1-mini"))
|
|
34
|
+
|
|
35
|
+
span.setdefault("metadata", {})["model"] = resolved_model
|
|
36
|
+
span["tokens"] = {
|
|
37
|
+
"input": input_tokens,
|
|
38
|
+
"output": output_tokens,
|
|
39
|
+
"total": total_tokens,
|
|
40
|
+
}
|
|
41
|
+
span["cost"] = calculate_cost(resolved_model, input_tokens, output_tokens)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def instrument_gemini(client: Any) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Auto-instruments Google Gemini genai client (client.models.generate_content)
|
|
47
|
+
to automatically extract tokens and calculate costs for any enclosing span.
|
|
48
|
+
"""
|
|
49
|
+
from .tracer import current_span
|
|
50
|
+
|
|
51
|
+
if not hasattr(client, "models") or not hasattr(client.models, "generate_content"):
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
original_generate = client.models.generate_content
|
|
55
|
+
|
|
56
|
+
@functools.wraps(original_generate)
|
|
57
|
+
def wrapped_generate(*args, **kwargs):
|
|
58
|
+
span = current_span.get()
|
|
59
|
+
model_name = kwargs.get("model", args[0] if args else "gemini-3.8-flash")
|
|
60
|
+
if span is not None:
|
|
61
|
+
span.setdefault("metadata", {})["model"] = model_name
|
|
62
|
+
|
|
63
|
+
response = original_generate(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
if span is not None:
|
|
66
|
+
save_token_usage(response, span, model=model_name)
|
|
67
|
+
return response
|
|
68
|
+
|
|
69
|
+
client.models.generate_content = wrapped_generate
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def instrument_openai(client: Any) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Auto-instruments OpenAI / ASI client (client.chat.completions.create)
|
|
75
|
+
to capture token usage and calculate costs.
|
|
76
|
+
"""
|
|
77
|
+
from .tracer import current_span
|
|
78
|
+
|
|
79
|
+
if not hasattr(client, "chat") or not hasattr(client.chat, "completions"):
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
original_create = client.chat.completions.create
|
|
83
|
+
|
|
84
|
+
@functools.wraps(original_create)
|
|
85
|
+
def wrapped_create(*args, **kwargs):
|
|
86
|
+
span = current_span.get()
|
|
87
|
+
model_name = kwargs.get("model", "asi1-mini")
|
|
88
|
+
if span is not None:
|
|
89
|
+
span.setdefault("metadata", {})["model"] = model_name
|
|
90
|
+
|
|
91
|
+
response = original_create(*args, **kwargs)
|
|
92
|
+
|
|
93
|
+
if span is not None:
|
|
94
|
+
save_token_usage(response, span, model=model_name)
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
client.chat.completions.create = wrapped_create
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def instrument_client(client: Any) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Automatically detects LLM client type (Google Gemini, OpenAI, or ASI)
|
|
103
|
+
and applies tracing instrumentation.
|
|
104
|
+
"""
|
|
105
|
+
if hasattr(client, "models") and hasattr(client.models, "generate_content"):
|
|
106
|
+
instrument_gemini(client)
|
|
107
|
+
elif hasattr(client, "chat") and hasattr(client.chat, "completions"):
|
|
108
|
+
instrument_openai(client)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Backward-compatible alias
|
|
112
|
+
instrument_llm_provider = instrument_client
|
|
113
|
+
|
|
114
|
+
|
asi_telemetry/metrics.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metrics tracking and runtime performance monitoring for AI agents and tools.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.request
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from functools import wraps
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MetricsTracker:
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
agent_name: str = "assistant",
|
|
22
|
+
agent_address: str = "",
|
|
23
|
+
api_key: str = "",
|
|
24
|
+
backend_url: str = "",
|
|
25
|
+
):
|
|
26
|
+
self.agent_name = agent_name
|
|
27
|
+
self.agent_address = agent_address
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.backend_url = backend_url
|
|
30
|
+
|
|
31
|
+
# Sync process-level agent identity and API key with tracer
|
|
32
|
+
try:
|
|
33
|
+
from .tracer import set_agent_info, set_api_key
|
|
34
|
+
set_agent_info(
|
|
35
|
+
agent_name=agent_name,
|
|
36
|
+
agent_address=agent_address,
|
|
37
|
+
)
|
|
38
|
+
if self.api_key:
|
|
39
|
+
set_api_key(self.api_key)
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def _get_metrics_url(self) -> str:
|
|
44
|
+
url = (
|
|
45
|
+
self.backend_url
|
|
46
|
+
or os.getenv("TELEMETRY_METRICS_URL")
|
|
47
|
+
or os.getenv("TELEMETRY_BACKEND_URL")
|
|
48
|
+
or "http://127.0.0.1:8080"
|
|
49
|
+
)
|
|
50
|
+
if "/api/llm/traces" in url:
|
|
51
|
+
url = url.replace("/api/llm/traces", "/api/metrics")
|
|
52
|
+
elif not url.endswith("/api/metrics") and not url.endswith("/api/telemetry"):
|
|
53
|
+
url = f"{url.rstrip('/')}/api/metrics"
|
|
54
|
+
return url
|
|
55
|
+
|
|
56
|
+
def _dispatch_payload(self, payload: dict) -> None:
|
|
57
|
+
def _worker():
|
|
58
|
+
url = self._get_metrics_url()
|
|
59
|
+
try:
|
|
60
|
+
headers = {"Content-Type": "application/json"}
|
|
61
|
+
api_key = (
|
|
62
|
+
self.api_key
|
|
63
|
+
or os.getenv("ASI_API_KEY")
|
|
64
|
+
or os.getenv("ASI_TELEMETRY_API_KEY")
|
|
65
|
+
or os.getenv("TELEMETRY_API_KEY")
|
|
66
|
+
)
|
|
67
|
+
if not api_key:
|
|
68
|
+
try:
|
|
69
|
+
from .tracer import get_api_key
|
|
70
|
+
api_key = get_api_key()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
if api_key:
|
|
75
|
+
headers["x-api-key"] = api_key
|
|
76
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
77
|
+
payload["api_key"] = api_key
|
|
78
|
+
|
|
79
|
+
data = json.dumps(payload, default=str).encode("utf-8")
|
|
80
|
+
req = urllib.request.Request(
|
|
81
|
+
url,
|
|
82
|
+
data=data,
|
|
83
|
+
headers=headers,
|
|
84
|
+
method="POST",
|
|
85
|
+
)
|
|
86
|
+
with urllib.request.urlopen(req, timeout=3.0):
|
|
87
|
+
pass
|
|
88
|
+
except Exception as e:
|
|
89
|
+
# Log when debugging is requested
|
|
90
|
+
if os.getenv("ASI_DEBUG") or os.getenv("TELEMETRY_DEBUG"):
|
|
91
|
+
print(f"[ASI-Telemetry Metrics Error] Failed to send metrics to {url}: {e}")
|
|
92
|
+
|
|
93
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
94
|
+
|
|
95
|
+
def track(self, tool_name: Optional[Any] = None) -> Callable:
|
|
96
|
+
"""
|
|
97
|
+
Decorator to track function execution time, status, and agent metrics.
|
|
98
|
+
Can be used as:
|
|
99
|
+
@monitor.track
|
|
100
|
+
@monitor.track()
|
|
101
|
+
@monitor.track("custom_name")
|
|
102
|
+
Supports both sync and async functions.
|
|
103
|
+
"""
|
|
104
|
+
def decorator(func: Callable) -> Callable:
|
|
105
|
+
target_name = (tool_name if isinstance(tool_name, str) else None) or func.__name__
|
|
106
|
+
|
|
107
|
+
if inspect.iscoroutinefunction(func):
|
|
108
|
+
@wraps(func)
|
|
109
|
+
async def async_wrapper(*args, **kwargs):
|
|
110
|
+
start_time = time.perf_counter()
|
|
111
|
+
status = "success"
|
|
112
|
+
try:
|
|
113
|
+
return await func(*args, **kwargs)
|
|
114
|
+
except asyncio.CancelledError:
|
|
115
|
+
status = "cancelled"
|
|
116
|
+
raise
|
|
117
|
+
except Exception:
|
|
118
|
+
status = "error"
|
|
119
|
+
raise
|
|
120
|
+
finally:
|
|
121
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
122
|
+
payload = {
|
|
123
|
+
"agent_name": self.agent_name,
|
|
124
|
+
"agent_address": self.agent_address,
|
|
125
|
+
"func_name": target_name,
|
|
126
|
+
"status": status,
|
|
127
|
+
"response_time_ms": round(duration_ms, 2),
|
|
128
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
129
|
+
}
|
|
130
|
+
self._dispatch_payload(payload)
|
|
131
|
+
|
|
132
|
+
return async_wrapper
|
|
133
|
+
else:
|
|
134
|
+
@wraps(func)
|
|
135
|
+
def sync_wrapper(*args, **kwargs):
|
|
136
|
+
start_time = time.perf_counter()
|
|
137
|
+
status = "success"
|
|
138
|
+
try:
|
|
139
|
+
return func(*args, **kwargs)
|
|
140
|
+
except Exception:
|
|
141
|
+
status = "error"
|
|
142
|
+
raise
|
|
143
|
+
finally:
|
|
144
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
145
|
+
payload = {
|
|
146
|
+
"agent_name": self.agent_name,
|
|
147
|
+
"agent_address": self.agent_address,
|
|
148
|
+
"func_name": target_name,
|
|
149
|
+
"status": status,
|
|
150
|
+
"response_time_ms": round(duration_ms, 2),
|
|
151
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
152
|
+
}
|
|
153
|
+
self._dispatch_payload(payload)
|
|
154
|
+
|
|
155
|
+
return sync_wrapper
|
|
156
|
+
|
|
157
|
+
if callable(tool_name):
|
|
158
|
+
func = tool_name
|
|
159
|
+
tool_name = None
|
|
160
|
+
return decorator(func)
|
|
161
|
+
|
|
162
|
+
return decorator
|
|
163
|
+
|
|
164
|
+
|
asi_telemetry/models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models, pricing tables, and schema constants for ASI Telemetry.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
# Price in USD per 1 million tokens
|
|
8
|
+
PRICE_PER_MILLION: Dict[str, Dict[str, float]] = {
|
|
9
|
+
"asi1-mini": {"input": 0.10, "output": 0.40},
|
|
10
|
+
"asi-mini": {"input": 0.10, "output": 0.40},
|
|
11
|
+
"asi1": {"input": 0.50, "output": 1.50},
|
|
12
|
+
"gemini-3.8-flash": {"input": 0.075, "output": 0.30},
|
|
13
|
+
"gemini-2.5-flash": {"input": 0.075, "output": 0.30},
|
|
14
|
+
"gemini-3.5-flash": {"input": 0.075, "output": 0.30},
|
|
15
|
+
"gemini-3.5-flash-lite": {"input": 0.0375, "output": 0.15}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
DEFAULT_PRICE: Dict[str, float] = {"input": 0.10, "output": 0.40}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
|
22
|
+
"""
|
|
23
|
+
Calculates estimated USD cost for a given model and token counts.
|
|
24
|
+
"""
|
|
25
|
+
normalized_model = (model or "").replace("models/", "").lower()
|
|
26
|
+
price = PRICE_PER_MILLION.get(normalized_model, DEFAULT_PRICE)
|
|
27
|
+
|
|
28
|
+
input_cost = (input_tokens * price["input"]) / 1_000_000
|
|
29
|
+
output_cost = (output_tokens * price["output"]) / 1_000_000
|
|
30
|
+
return round(input_cost + output_cost, 6)
|
asi_telemetry/tracer.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.request
|
|
8
|
+
import uuid
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from .instrumentation import save_token_usage
|
|
13
|
+
|
|
14
|
+
sdk_config: Dict[str, Any] = {
|
|
15
|
+
"api_key": os.getenv("ASI_API_KEY") or os.getenv("ASI_TELEMETRY_API_KEY") or os.getenv("TELEMETRY_API_KEY"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
current_agent: Dict[str, Optional[str]] = {
|
|
19
|
+
"agent_name": os.getenv("AGENT_NAME", "assistant"),
|
|
20
|
+
"agent_address": os.getenv("AGENT_ADDRESS", None),
|
|
21
|
+
}
|
|
22
|
+
CURRENT_AGENT_INFO = current_agent
|
|
23
|
+
|
|
24
|
+
active_span: ContextVar[Optional[Dict[str, Any]]] = ContextVar("active_span", default=None)
|
|
25
|
+
current_span = active_span
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_api_key(api_key: str) -> None:
|
|
29
|
+
sdk_config["api_key"] = api_key
|
|
30
|
+
os.environ["ASI_API_KEY"] = api_key
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_api_key() -> Optional[str]:
|
|
34
|
+
return sdk_config.get("api_key") or os.getenv("ASI_API_KEY") or os.getenv("ASI_TELEMETRY_API_KEY") or os.getenv("TELEMETRY_API_KEY")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_agent_info(agent_name: Optional[str] = None, agent_address: Optional[str] = None) -> None:
|
|
38
|
+
if agent_name:
|
|
39
|
+
current_agent["agent_name"] = agent_name
|
|
40
|
+
if agent_address:
|
|
41
|
+
current_agent["agent_address"] = agent_address
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_agent_info() -> Dict[str, Optional[str]]:
|
|
45
|
+
return dict(current_agent)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_current_trace_id() -> Optional[str]:
|
|
49
|
+
span = active_span.get()
|
|
50
|
+
return span["trace_id"] if span else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_current_span_id() -> Optional[str]:
|
|
54
|
+
span = active_span.get()
|
|
55
|
+
return span["span_id"] if span else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_trace_context() -> Dict[str, Optional[str]]:
|
|
59
|
+
span = active_span.get()
|
|
60
|
+
if span:
|
|
61
|
+
return {"trace_id": span["trace_id"], "parent_id": span["span_id"]}
|
|
62
|
+
return {"trace_id": str(uuid.uuid4()), "parent_id": None}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_trace_context(args: tuple, kwargs: dict, metadata: Optional[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]:
|
|
66
|
+
candidates = [metadata] + list(args) + list(kwargs.values())
|
|
67
|
+
for item in candidates:
|
|
68
|
+
ctx = getattr(item, "trace_context", None) or (item.get("trace_context") if isinstance(item, dict) else item)
|
|
69
|
+
if isinstance(ctx, dict) and ctx.get("trace_id"):
|
|
70
|
+
return ctx["trace_id"], ctx.get("parent_id") or ctx.get("span_id")
|
|
71
|
+
return None, None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _dispatch_span(payload: Dict[str, Any]) -> None:
|
|
75
|
+
def _worker():
|
|
76
|
+
try:
|
|
77
|
+
url = os.getenv("TELEMETRY_TRACES_URL") or os.getenv("TELEMETRY_BACKEND_URL", "http://127.0.0.1:8080/api/llm/traces")
|
|
78
|
+
if "/api/metrics" in url:
|
|
79
|
+
url = url.replace("/api/metrics", "/api/llm/traces")
|
|
80
|
+
|
|
81
|
+
api_key = get_api_key()
|
|
82
|
+
headers = {"Content-Type": "application/json"}
|
|
83
|
+
if api_key:
|
|
84
|
+
headers["x-api-key"] = api_key
|
|
85
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
86
|
+
payload["api_key"] = api_key
|
|
87
|
+
|
|
88
|
+
req = urllib.request.Request(
|
|
89
|
+
url,
|
|
90
|
+
data=json.dumps(payload, default=str).encode("utf-8"),
|
|
91
|
+
headers=headers,
|
|
92
|
+
method="POST",
|
|
93
|
+
)
|
|
94
|
+
with urllib.request.urlopen(req, timeout=2.0):
|
|
95
|
+
pass
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
threading.Thread(target=_worker, daemon=True).start()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def start_span(
|
|
103
|
+
function_name: str,
|
|
104
|
+
run_type: str,
|
|
105
|
+
metadata: Optional[Dict[str, Any]],
|
|
106
|
+
args: tuple,
|
|
107
|
+
kwargs: dict,
|
|
108
|
+
agent_address: Optional[str] = None,
|
|
109
|
+
) -> Tuple[Dict[str, Any], Any]:
|
|
110
|
+
parent_span = active_span.get()
|
|
111
|
+
incoming_trace_id, incoming_parent_id = _extract_trace_context(args, kwargs, metadata)
|
|
112
|
+
|
|
113
|
+
if incoming_trace_id:
|
|
114
|
+
trace_id = incoming_trace_id
|
|
115
|
+
parent_id = incoming_parent_id or (parent_span["span_id"] if parent_span else None)
|
|
116
|
+
elif parent_span:
|
|
117
|
+
trace_id = parent_span["trace_id"]
|
|
118
|
+
parent_id = parent_span["span_id"]
|
|
119
|
+
else:
|
|
120
|
+
trace_id = str(uuid.uuid4())
|
|
121
|
+
parent_id = None
|
|
122
|
+
|
|
123
|
+
is_parent_root = (parent_span is None)
|
|
124
|
+
agent_name = current_agent.get("agent_name", "assistant")
|
|
125
|
+
meta = dict(metadata or {})
|
|
126
|
+
meta["is_agent_root"] = is_parent_root
|
|
127
|
+
meta["agent_name"] = agent_name
|
|
128
|
+
|
|
129
|
+
addr = agent_address or meta.get("agent_address") or (parent_span.get("agent_address") if parent_span else None) or current_agent.get("agent_address")
|
|
130
|
+
span_id = str(uuid.uuid4())
|
|
131
|
+
inputs = kwargs if kwargs else (args[0] if len(args) == 1 else args)
|
|
132
|
+
|
|
133
|
+
span = {
|
|
134
|
+
"id": span_id,
|
|
135
|
+
"span_id": span_id,
|
|
136
|
+
"trace_id": trace_id,
|
|
137
|
+
"parent_id": parent_id,
|
|
138
|
+
"name": function_name,
|
|
139
|
+
"run_type": run_type,
|
|
140
|
+
"start_time": time.time(),
|
|
141
|
+
"inputs": inputs,
|
|
142
|
+
"outputs": None,
|
|
143
|
+
"error": None,
|
|
144
|
+
"tokens": {"input": 0, "output": 0, "total": 0},
|
|
145
|
+
"cost": 0.0,
|
|
146
|
+
"agent_name": agent_name,
|
|
147
|
+
"is_agent_root": is_parent_root,
|
|
148
|
+
"agent_address": addr,
|
|
149
|
+
"metadata": meta,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
reset_token = active_span.set(span)
|
|
153
|
+
return span, reset_token
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def end_span(
|
|
157
|
+
span: Dict[str, Any],
|
|
158
|
+
reset_token: Any,
|
|
159
|
+
result: Any = None,
|
|
160
|
+
error: Optional[Exception] = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
try:
|
|
163
|
+
span["end_time"] = time.time()
|
|
164
|
+
span["duration_ms"] = round((span["end_time"] - span["start_time"]) * 1000, 2)
|
|
165
|
+
if error is not None:
|
|
166
|
+
span["error"] = str(error)
|
|
167
|
+
else:
|
|
168
|
+
span["outputs"] = result
|
|
169
|
+
save_token_usage(result, span)
|
|
170
|
+
finally:
|
|
171
|
+
active_span.reset(reset_token)
|
|
172
|
+
|
|
173
|
+
in_val = span.get("inputs")
|
|
174
|
+
out_val = span.get("outputs")
|
|
175
|
+
meta = dict(span.get("metadata") or {})
|
|
176
|
+
addr = span.get("agent_address")
|
|
177
|
+
if addr:
|
|
178
|
+
meta["agent_address"] = addr
|
|
179
|
+
meta["agent_name"] = span.get("agent_name", "assistant")
|
|
180
|
+
meta["is_agent_root"] = span.get("is_agent_root", False)
|
|
181
|
+
|
|
182
|
+
payload = {
|
|
183
|
+
"id": span["span_id"],
|
|
184
|
+
"trace_id": span["trace_id"],
|
|
185
|
+
"span_id": span["span_id"],
|
|
186
|
+
"parent_id": span.get("parent_id"),
|
|
187
|
+
"name": span.get("name") or "agent_execution",
|
|
188
|
+
"run_type": span.get("run_type", "agent"),
|
|
189
|
+
"status": "error" if span.get("error") else "success",
|
|
190
|
+
"model": meta.get("model", "agent-tool"),
|
|
191
|
+
"input": json.dumps(in_val, default=str) if isinstance(in_val, (dict, list)) else str(in_val or ""),
|
|
192
|
+
"output": json.dumps(out_val, default=str) if isinstance(out_val, (dict, list)) else str(out_val or ""),
|
|
193
|
+
"error": str(span["error"]) if span.get("error") else None,
|
|
194
|
+
"latency_ms": span.get("duration_ms", 0.0),
|
|
195
|
+
"prompt_tokens": span.get("tokens", {}).get("input", 0),
|
|
196
|
+
"completion_tokens": span.get("tokens", {}).get("output", 0),
|
|
197
|
+
"total_tokens": span.get("tokens", {}).get("total", 0),
|
|
198
|
+
"cost_usd": span.get("cost", 0.0),
|
|
199
|
+
"agent_address": addr,
|
|
200
|
+
"agent_name": meta["agent_name"],
|
|
201
|
+
"is_agent_root": meta["is_agent_root"],
|
|
202
|
+
"metadata": meta,
|
|
203
|
+
}
|
|
204
|
+
_dispatch_span(payload)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def traceable(
|
|
208
|
+
name: Optional[Any] = None,
|
|
209
|
+
run_type: str = "chain",
|
|
210
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
211
|
+
agent_address: Optional[str] = None,
|
|
212
|
+
) -> Callable:
|
|
213
|
+
def decorator(func: Callable) -> Callable:
|
|
214
|
+
fn_name = (name if isinstance(name, str) else None) or func.__name__
|
|
215
|
+
|
|
216
|
+
if inspect.iscoroutinefunction(func):
|
|
217
|
+
@functools.wraps(func)
|
|
218
|
+
async def async_wrapper(*args, **kwargs):
|
|
219
|
+
span, token = start_span(fn_name, run_type, metadata, args, kwargs, agent_address)
|
|
220
|
+
result, error = None, None
|
|
221
|
+
try:
|
|
222
|
+
result = await func(*args, **kwargs)
|
|
223
|
+
return result
|
|
224
|
+
except Exception as e:
|
|
225
|
+
error = e
|
|
226
|
+
raise
|
|
227
|
+
finally:
|
|
228
|
+
end_span(span, token, result=result, error=error)
|
|
229
|
+
return async_wrapper
|
|
230
|
+
|
|
231
|
+
@functools.wraps(func)
|
|
232
|
+
def sync_wrapper(*args, **kwargs):
|
|
233
|
+
span, token = start_span(fn_name, run_type, metadata, args, kwargs, agent_address)
|
|
234
|
+
result, error = None, None
|
|
235
|
+
try:
|
|
236
|
+
result = func(*args, **kwargs)
|
|
237
|
+
return result
|
|
238
|
+
except Exception as e:
|
|
239
|
+
error = e
|
|
240
|
+
raise
|
|
241
|
+
finally:
|
|
242
|
+
end_span(span, token, result=result, error=error)
|
|
243
|
+
return sync_wrapper
|
|
244
|
+
|
|
245
|
+
if callable(name):
|
|
246
|
+
func = name
|
|
247
|
+
name = None
|
|
248
|
+
return decorator(func)
|
|
249
|
+
|
|
250
|
+
return decorator
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: asi-telemetry
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Observability and Distributed Tracing SDK for AI Agents and LLMs
|
|
5
|
+
Author: Devrithm Labs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Devrithm-Labs/ASI-Telemetry
|
|
8
|
+
Project-URL: Repository, https://github.com/Devrithm-Labs/ASI-Telemetry
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Devrithm-Labs/ASI-Telemetry/issues
|
|
10
|
+
Keywords: telemetry,tracing,observability,ai-agents,llm,metrics,gemini,openai
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# ASI Telemetry SDK
|
|
28
|
+
|
|
29
|
+
The official lightweight observability, runtime metrics, and distributed tracing SDK for AI agents, LLMs (Google Gemini, OpenAI, ASI-1, DeepSeek), and tool execution pipelines.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install asi-telemetry
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Zero external dependencies required (built using standard Python libraries).
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## Architecture & Features
|
|
41
|
+
|
|
42
|
+
- **Agent & Tool Performance Tracking (`MetricsTracker`)**:
|
|
43
|
+
- Measures execution latency, request counts, and success/error status.
|
|
44
|
+
- Asynchronously dispatches metrics to the ASI Telemetry backend and ClickHouse.
|
|
45
|
+
- Zero performance overhead; non-blocking background workers.
|
|
46
|
+
- Clean real-time terminal telemetry reporting.
|
|
47
|
+
|
|
48
|
+
- **Distributed LLM Tracing (`@traceable`)**:
|
|
49
|
+
- Trace multi-hop agent swarms and workflow chains with distributed context propagation (`trace_id`, `span_id`, `parent_id`).
|
|
50
|
+
- Supports both synchronous and asynchronous coroutine functions.
|
|
51
|
+
- Native auto-instrumentation for Google Gemini (`instrument_gemini`) and OpenAI/ASI (`instrument_openai` / `instrument_client`).
|
|
52
|
+
- Real-time token usage calculation and cost estimation per model.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
### 1. Monitoring Agent Tools & Functions
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from asi_telemetry import MetricsTracker
|
|
62
|
+
|
|
63
|
+
tracker = MetricsTracker(agent_name="assistant")
|
|
64
|
+
|
|
65
|
+
@tracker.track(tool_name="weather_tool")
|
|
66
|
+
async def get_weather(city: str):
|
|
67
|
+
return f"Weather in {city}: 72°F Sunny"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
*(Note: `AgentMonitor` is retained as a backward-compatible alias for `MetricsTracker`)*
|
|
71
|
+
|
|
72
|
+
### 2. Tracing LLMs and Multi-Agent Workflows
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from asi_telemetry import traceable, instrument_gemini
|
|
76
|
+
from google import genai
|
|
77
|
+
|
|
78
|
+
client = genai.Client(api_key="YOUR_API_KEY")
|
|
79
|
+
instrument_gemini(client)
|
|
80
|
+
|
|
81
|
+
@traceable(name="generate_summary", run_type="llm")
|
|
82
|
+
def summarize(text: str):
|
|
83
|
+
response = client.models.generate_content(
|
|
84
|
+
model="gemini-2.5-flash",
|
|
85
|
+
contents=f"Summarize this: {text}"
|
|
86
|
+
)
|
|
87
|
+
return response.text
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 3. Distributed Context Propagation Across Swarms
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from asi_telemetry import traceable, create_trace_context
|
|
94
|
+
|
|
95
|
+
@traceable(name="orchestrator", run_type="chain")
|
|
96
|
+
async def orchestrate():
|
|
97
|
+
# Generate trace context to pass across network boundaries
|
|
98
|
+
context = create_trace_context()
|
|
99
|
+
# Pass context={"trace_id": ..., "parent_id": ...} to sub-agents
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 4. Direct Cost Estimation
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from asi_telemetry import calculate_cost
|
|
106
|
+
|
|
107
|
+
# Approximate cost in USD for a model call
|
|
108
|
+
cost = calculate_cost(model="gemini-3.8-flash", input_tokens=1200, output_tokens=350)
|
|
109
|
+
print(f"Cost: ${cost}")
|
|
110
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
asi_telemetry/__init__.py,sha256=Yy3kkWOSOcI0gucwmD5ajKKkBU5tqUgWrsk-Knifkiw,5051
|
|
2
|
+
asi_telemetry/instrumentation.py,sha256=vekkmgs4ABc9nZ7PwKzq6NuKZQrXa-ZGappgV46DgpI,4095
|
|
3
|
+
asi_telemetry/metrics.py,sha256=6DfaTYWOR_KfL_Gfq4CbgfRr2THtt2_wU92VPKluDK0,6067
|
|
4
|
+
asi_telemetry/models.py,sha256=nINuvCy1QYHiEnQhZPG_GyIZRZayb8zcgM7p0FunZrA,1160
|
|
5
|
+
asi_telemetry/tracer.py,sha256=1WkMZNLNycJo6kw9OWH79V4DMMAIMduDthfNapS3HAg,8914
|
|
6
|
+
asi_telemetry-0.2.0.dist-info/licenses/LICENSE,sha256=PlFnyEAkISad-2_SedIpfPPiwEQw2LNnbqJHeYODFmo,1070
|
|
7
|
+
asi_telemetry-0.2.0.dist-info/METADATA,sha256=2IWHZN7oh8ysaXYL65dOtjYrNhF3lRk0-jYWpUgSMWA,3826
|
|
8
|
+
asi_telemetry-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
asi_telemetry-0.2.0.dist-info/top_level.txt,sha256=BX6GcwSwGF_RaNuy42JbI5pyRBTIw3lMMx2ntVr6kaY,14
|
|
10
|
+
asi_telemetry-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Devrithm Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
asi_telemetry
|