agentmeter-obs 0.1.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.
- agent_meter/__init__.py +7 -0
- agent_meter/client.py +192 -0
- agent_meter/types.py +77 -0
- agentmeter_obs-0.1.0.dist-info/METADATA +117 -0
- agentmeter_obs-0.1.0.dist-info/RECORD +6 -0
- agentmeter_obs-0.1.0.dist-info/WHEEL +4 -0
agent_meter/__init__.py
ADDED
agent_meter/client.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Agent Meter client — batches and flushes tool calls via OTLP JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from agent_meter.types import ToolCall
|
|
14
|
+
|
|
15
|
+
_DEFAULT_ENDPOINT = "http://localhost:8081"
|
|
16
|
+
_FLUSH_INTERVAL = 5.0 # seconds
|
|
17
|
+
_MAX_BATCH = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentMeter:
|
|
21
|
+
"""Lightweight client that buffers tool calls and flushes to agent-meter.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
from agent_meter import AgentMeter
|
|
26
|
+
|
|
27
|
+
am = AgentMeter(api_key="am_live_...")
|
|
28
|
+
span = am.track("my_tool", model="gpt-4o")
|
|
29
|
+
# ... do work ...
|
|
30
|
+
span.finish()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: Optional[str] = None,
|
|
36
|
+
endpoint: Optional[str] = None,
|
|
37
|
+
ide: Optional[str] = None,
|
|
38
|
+
agent: Optional[str] = None,
|
|
39
|
+
flush_interval: float = _FLUSH_INTERVAL,
|
|
40
|
+
):
|
|
41
|
+
self._api_key = api_key or os.environ.get("AGENT_METER_API_KEY", "")
|
|
42
|
+
self._endpoint = (
|
|
43
|
+
endpoint or os.environ.get("AGENT_METER_ENDPOINT", _DEFAULT_ENDPOINT)
|
|
44
|
+
).rstrip("/")
|
|
45
|
+
self._ide = ide or os.environ.get("AGENT_METER_IDE", "python-sdk")
|
|
46
|
+
self._agent = agent or os.environ.get("AGENT_METER_AGENT")
|
|
47
|
+
self._buffer: list[ToolCall] = []
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._flush_interval = flush_interval
|
|
50
|
+
self._closed = False
|
|
51
|
+
|
|
52
|
+
# Background flush thread
|
|
53
|
+
self._timer: Optional[threading.Timer] = None
|
|
54
|
+
self._schedule_flush()
|
|
55
|
+
atexit.register(self.shutdown)
|
|
56
|
+
|
|
57
|
+
def track(
|
|
58
|
+
self,
|
|
59
|
+
tool_name: str,
|
|
60
|
+
*,
|
|
61
|
+
model: Optional[str] = None,
|
|
62
|
+
mcp_server: Optional[str] = None,
|
|
63
|
+
conversation_id: Optional[str] = None,
|
|
64
|
+
task_id: Optional[str] = None,
|
|
65
|
+
parent_span_id: Optional[str] = None,
|
|
66
|
+
) -> ToolCall:
|
|
67
|
+
"""Start tracking a tool call. Call .finish() when done."""
|
|
68
|
+
span = ToolCall(
|
|
69
|
+
tool_name=tool_name,
|
|
70
|
+
model=model,
|
|
71
|
+
mcp_server=mcp_server,
|
|
72
|
+
ide=self._ide,
|
|
73
|
+
agent=self._agent,
|
|
74
|
+
conversation_id=conversation_id,
|
|
75
|
+
task_id=task_id,
|
|
76
|
+
parent_span_id=parent_span_id,
|
|
77
|
+
)
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._buffer.append(span)
|
|
80
|
+
return span
|
|
81
|
+
|
|
82
|
+
def flush(self) -> int:
|
|
83
|
+
"""Flush buffered spans to the server. Returns count sent."""
|
|
84
|
+
with self._lock:
|
|
85
|
+
if not self._buffer:
|
|
86
|
+
return 0
|
|
87
|
+
batch = self._buffer[:_MAX_BATCH]
|
|
88
|
+
self._buffer = self._buffer[_MAX_BATCH:]
|
|
89
|
+
|
|
90
|
+
payload = self._build_otlp_payload(batch)
|
|
91
|
+
try:
|
|
92
|
+
resp = httpx.post(
|
|
93
|
+
f"{self._endpoint}/v1/traces",
|
|
94
|
+
json=payload,
|
|
95
|
+
headers=self._headers(),
|
|
96
|
+
timeout=10.0,
|
|
97
|
+
)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
except httpx.HTTPError:
|
|
100
|
+
# Re-queue on failure (best-effort)
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._buffer = batch + self._buffer
|
|
103
|
+
return 0
|
|
104
|
+
return len(batch)
|
|
105
|
+
|
|
106
|
+
def shutdown(self) -> None:
|
|
107
|
+
"""Flush remaining spans and stop background thread."""
|
|
108
|
+
self._closed = True
|
|
109
|
+
if self._timer:
|
|
110
|
+
self._timer.cancel()
|
|
111
|
+
# Flush all remaining
|
|
112
|
+
while True:
|
|
113
|
+
sent = self.flush()
|
|
114
|
+
if sent == 0:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
def _schedule_flush(self) -> None:
|
|
118
|
+
if self._closed:
|
|
119
|
+
return
|
|
120
|
+
self._timer = threading.Timer(self._flush_interval, self._tick)
|
|
121
|
+
self._timer.daemon = True
|
|
122
|
+
self._timer.start()
|
|
123
|
+
|
|
124
|
+
def _tick(self) -> None:
|
|
125
|
+
self.flush()
|
|
126
|
+
self._schedule_flush()
|
|
127
|
+
|
|
128
|
+
def _headers(self) -> dict[str, str]:
|
|
129
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
130
|
+
if self._api_key:
|
|
131
|
+
h["Authorization"] = f"Bearer {self._api_key}"
|
|
132
|
+
return h
|
|
133
|
+
|
|
134
|
+
def _build_otlp_payload(self, spans: list[ToolCall]) -> dict:
|
|
135
|
+
"""Build OTLP-compatible JSON payload."""
|
|
136
|
+
otlp_spans = []
|
|
137
|
+
for s in spans:
|
|
138
|
+
attrs = [{"key": "tool.name", "value": {"stringValue": s.tool_name}}]
|
|
139
|
+
if s.model:
|
|
140
|
+
attrs.append({"key": "gen_ai.request.model", "value": {"stringValue": s.model}})
|
|
141
|
+
if s.mcp_server:
|
|
142
|
+
attrs.append({"key": "mcp.server", "value": {"stringValue": s.mcp_server}})
|
|
143
|
+
if s.ide:
|
|
144
|
+
attrs.append({"key": "ide", "value": {"stringValue": s.ide}})
|
|
145
|
+
if s.agent:
|
|
146
|
+
attrs.append({"key": "agent", "value": {"stringValue": s.agent}})
|
|
147
|
+
if s.conversation_id:
|
|
148
|
+
attrs.append({"key": "session.id", "value": {"stringValue": s.conversation_id}})
|
|
149
|
+
if s.task_id:
|
|
150
|
+
attrs.append({"key": "task.id", "value": {"stringValue": s.task_id}})
|
|
151
|
+
if s.estimated_input_tokens is not None:
|
|
152
|
+
attrs.append({"key": "gen_ai.usage.input_tokens", "value": {"intValue": str(s.estimated_input_tokens)}})
|
|
153
|
+
if s.estimated_output_tokens is not None:
|
|
154
|
+
attrs.append({"key": "gen_ai.usage.output_tokens", "value": {"intValue": str(s.estimated_output_tokens)}})
|
|
155
|
+
if s.usd_cost is not None:
|
|
156
|
+
attrs.append({"key": "cost.usd", "value": {"doubleValue": s.usd_cost}})
|
|
157
|
+
if s.error:
|
|
158
|
+
attrs.append({"key": "error.message", "value": {"stringValue": s.error}})
|
|
159
|
+
|
|
160
|
+
end_ns = s.ended_at_ns or s.started_at_ns
|
|
161
|
+
status_code = 1 if s.ok else 2
|
|
162
|
+
status_msg = s.error or "" if not s.ok else ""
|
|
163
|
+
|
|
164
|
+
otlp_spans.append({
|
|
165
|
+
"traceId": s.trace_id,
|
|
166
|
+
"spanId": s.span_id,
|
|
167
|
+
"parentSpanId": s.parent_span_id or "",
|
|
168
|
+
"name": s.tool_name,
|
|
169
|
+
"kind": 3, # INTERNAL
|
|
170
|
+
"startTimeUnixNano": str(s.started_at_ns),
|
|
171
|
+
"endTimeUnixNano": str(end_ns),
|
|
172
|
+
"attributes": attrs,
|
|
173
|
+
"status": {"code": status_code, "message": status_msg},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"resourceSpans": [
|
|
178
|
+
{
|
|
179
|
+
"resource": {
|
|
180
|
+
"attributes": [
|
|
181
|
+
{"key": "service.name", "value": {"stringValue": "agent-meter-sdk-python"}},
|
|
182
|
+
]
|
|
183
|
+
},
|
|
184
|
+
"scopeSpans": [
|
|
185
|
+
{
|
|
186
|
+
"scope": {"name": "agent-meter-python", "version": "0.1.0"},
|
|
187
|
+
"spans": otlp_spans,
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
}
|
agent_meter/types.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Data types for Agent Meter spans/events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ToolCall:
|
|
13
|
+
"""A single tool call (span) to be reported."""
|
|
14
|
+
|
|
15
|
+
tool_name: str
|
|
16
|
+
started_at_ns: int = field(default_factory=lambda: time.time_ns())
|
|
17
|
+
ended_at_ns: Optional[int] = None
|
|
18
|
+
duration_ms: Optional[int] = None
|
|
19
|
+
ok: bool = True
|
|
20
|
+
error: Optional[str] = None
|
|
21
|
+
model: Optional[str] = None
|
|
22
|
+
mcp_server: Optional[str] = None
|
|
23
|
+
ide: Optional[str] = None
|
|
24
|
+
agent: Optional[str] = None
|
|
25
|
+
conversation_id: Optional[str] = None
|
|
26
|
+
task_id: Optional[str] = None
|
|
27
|
+
trace_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
28
|
+
span_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
|
|
29
|
+
parent_span_id: Optional[str] = None
|
|
30
|
+
estimated_input_tokens: Optional[int] = None
|
|
31
|
+
estimated_output_tokens: Optional[int] = None
|
|
32
|
+
usd_cost: Optional[float] = None
|
|
33
|
+
|
|
34
|
+
def finish(self, ok: bool = True, error: Optional[str] = None) -> None:
|
|
35
|
+
"""Mark the tool call as finished."""
|
|
36
|
+
self.ended_at_ns = time.time_ns()
|
|
37
|
+
self.duration_ms = (self.ended_at_ns - self.started_at_ns) // 1_000_000
|
|
38
|
+
self.ok = ok
|
|
39
|
+
if error:
|
|
40
|
+
self.error = error
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
"""Serialize to the ingest payload format."""
|
|
44
|
+
d: dict = {
|
|
45
|
+
"tool_name": self.tool_name,
|
|
46
|
+
"started_at": self.started_at_ns,
|
|
47
|
+
"ok": self.ok,
|
|
48
|
+
"trace_id": self.trace_id,
|
|
49
|
+
"span_id": self.span_id,
|
|
50
|
+
}
|
|
51
|
+
if self.ended_at_ns:
|
|
52
|
+
d["ended_at"] = self.ended_at_ns
|
|
53
|
+
if self.duration_ms is not None:
|
|
54
|
+
d["duration_ms"] = self.duration_ms
|
|
55
|
+
if self.error:
|
|
56
|
+
d["error"] = self.error
|
|
57
|
+
if self.model:
|
|
58
|
+
d["model"] = self.model
|
|
59
|
+
if self.mcp_server:
|
|
60
|
+
d["mcp_server"] = self.mcp_server
|
|
61
|
+
if self.ide:
|
|
62
|
+
d["ide"] = self.ide
|
|
63
|
+
if self.agent:
|
|
64
|
+
d["agent"] = self.agent
|
|
65
|
+
if self.conversation_id:
|
|
66
|
+
d["conversation_id"] = self.conversation_id
|
|
67
|
+
if self.task_id:
|
|
68
|
+
d["task_id"] = self.task_id
|
|
69
|
+
if self.parent_span_id:
|
|
70
|
+
d["parent_span_id"] = self.parent_span_id
|
|
71
|
+
if self.estimated_input_tokens is not None:
|
|
72
|
+
d["estimated_input_tokens"] = self.estimated_input_tokens
|
|
73
|
+
if self.estimated_output_tokens is not None:
|
|
74
|
+
d["estimated_output_tokens"] = self.estimated_output_tokens
|
|
75
|
+
if self.usd_cost is not None:
|
|
76
|
+
d["usd_cost"] = self.usd_cost
|
|
77
|
+
return d
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentmeter-obs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight SDK to track AI agent tool calls, costs, and latency.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dnorio/agent-meter
|
|
6
|
+
Project-URL: Repository, https://github.com/dnorio/agent-meter
|
|
7
|
+
Author-email: dnorio <dnorio@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agents,ai,llm,observability,telemetry
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx>=0.24
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# agent-meter
|
|
20
|
+
|
|
21
|
+
Lightweight Python SDK to track AI agent tool calls, costs, and latency.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Until the first PyPI release is published:
|
|
27
|
+
cd sdk/python
|
|
28
|
+
python3 -m pip install -e .
|
|
29
|
+
|
|
30
|
+
# After publication:
|
|
31
|
+
pip install agentmeter-obs
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start (60 seconds)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from agent_meter import AgentMeter
|
|
38
|
+
|
|
39
|
+
# Initialize (picks up AGENT_METER_API_KEY from env)
|
|
40
|
+
am = AgentMeter(api_key="am_live_...")
|
|
41
|
+
|
|
42
|
+
# Track a tool call
|
|
43
|
+
span = am.track("web_search", model="gpt-4o")
|
|
44
|
+
result = do_web_search(query)
|
|
45
|
+
span.finish()
|
|
46
|
+
|
|
47
|
+
# Track with tokens/cost
|
|
48
|
+
span = am.track("code_review", model="claude-sonnet-4-20250514")
|
|
49
|
+
span.estimated_input_tokens = 2000
|
|
50
|
+
span.estimated_output_tokens = 500
|
|
51
|
+
span.usd_cost = 0.012
|
|
52
|
+
span.finish()
|
|
53
|
+
|
|
54
|
+
# Flush happens automatically every 5s, or on exit
|
|
55
|
+
# Force flush:
|
|
56
|
+
am.flush()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
| Env Variable | Description | Default |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `AGENT_METER_API_KEY` | API key (`am_live_...`) | — |
|
|
64
|
+
| `AGENT_METER_ENDPOINT` | Server URL | `http://localhost:8081` |
|
|
65
|
+
| `AGENT_METER_IDE` | IDE identifier | `python-sdk` |
|
|
66
|
+
| `AGENT_METER_AGENT` | Agent name | — |
|
|
67
|
+
|
|
68
|
+
Or pass directly:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
am = AgentMeter(
|
|
72
|
+
api_key="am_live_...",
|
|
73
|
+
endpoint="http://localhost:3000",
|
|
74
|
+
ide="my-custom-agent",
|
|
75
|
+
agent="research-bot",
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Context Manager
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import contextlib
|
|
83
|
+
|
|
84
|
+
@contextlib.contextmanager
|
|
85
|
+
def tracked(am, tool_name, **kwargs):
|
|
86
|
+
span = am.track(tool_name, **kwargs)
|
|
87
|
+
try:
|
|
88
|
+
yield span
|
|
89
|
+
except Exception as e:
|
|
90
|
+
span.finish(ok=False, error=str(e))
|
|
91
|
+
raise
|
|
92
|
+
else:
|
|
93
|
+
span.finish()
|
|
94
|
+
|
|
95
|
+
# Usage
|
|
96
|
+
with tracked(am, "database_query", model="gpt-4o") as span:
|
|
97
|
+
results = db.execute(sql)
|
|
98
|
+
span.estimated_input_tokens = len(sql) // 4
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Conversations & Tasks
|
|
102
|
+
|
|
103
|
+
Group spans into conversations (sessions) and tasks:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
span = am.track(
|
|
107
|
+
"generate_code",
|
|
108
|
+
model="claude-sonnet-4-20250514",
|
|
109
|
+
conversation_id="conv-abc123",
|
|
110
|
+
task_id="implement-feature-x",
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Protocol
|
|
115
|
+
|
|
116
|
+
The SDK sends spans as OTLP-compatible JSON to `POST /v1/traces`. This is the
|
|
117
|
+
same format used by OpenTelemetry, making it compatible with any OTLP collector.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
agent_meter/__init__.py,sha256=tcdCsrykOVBYOVaUt4MPTa4dJZ4KknnwFGU0sD-Oa7A,216
|
|
2
|
+
agent_meter/client.py,sha256=tweqFDc6d4kzXy8GgkhE_pnWmaiGexnHmschzbo3g3Y,6654
|
|
3
|
+
agent_meter/types.py,sha256=08vQXF2ug-czsmQW0quW6By8rx3vZk2pXJXYIvA2m_M,2673
|
|
4
|
+
agentmeter_obs-0.1.0.dist-info/METADATA,sha256=6DTUtNG0cbe62TfldFUPnKKaF6nLdhfE9q5V6ry4dAM,2857
|
|
5
|
+
agentmeter_obs-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
agentmeter_obs-0.1.0.dist-info/RECORD,,
|