agentmeter-obs 0.1.0__tar.gz

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.
@@ -0,0 +1 @@
1
+ __pycache__/
@@ -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,99 @@
1
+ # agent-meter
2
+
3
+ Lightweight Python SDK to track AI agent tool calls, costs, and latency.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ # Until the first PyPI release is published:
9
+ cd sdk/python
10
+ python3 -m pip install -e .
11
+
12
+ # After publication:
13
+ pip install agentmeter-obs
14
+ ```
15
+
16
+ ## Quick Start (60 seconds)
17
+
18
+ ```python
19
+ from agent_meter import AgentMeter
20
+
21
+ # Initialize (picks up AGENT_METER_API_KEY from env)
22
+ am = AgentMeter(api_key="am_live_...")
23
+
24
+ # Track a tool call
25
+ span = am.track("web_search", model="gpt-4o")
26
+ result = do_web_search(query)
27
+ span.finish()
28
+
29
+ # Track with tokens/cost
30
+ span = am.track("code_review", model="claude-sonnet-4-20250514")
31
+ span.estimated_input_tokens = 2000
32
+ span.estimated_output_tokens = 500
33
+ span.usd_cost = 0.012
34
+ span.finish()
35
+
36
+ # Flush happens automatically every 5s, or on exit
37
+ # Force flush:
38
+ am.flush()
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ | Env Variable | Description | Default |
44
+ |---|---|---|
45
+ | `AGENT_METER_API_KEY` | API key (`am_live_...`) | — |
46
+ | `AGENT_METER_ENDPOINT` | Server URL | `http://localhost:8081` |
47
+ | `AGENT_METER_IDE` | IDE identifier | `python-sdk` |
48
+ | `AGENT_METER_AGENT` | Agent name | — |
49
+
50
+ Or pass directly:
51
+
52
+ ```python
53
+ am = AgentMeter(
54
+ api_key="am_live_...",
55
+ endpoint="http://localhost:3000",
56
+ ide="my-custom-agent",
57
+ agent="research-bot",
58
+ )
59
+ ```
60
+
61
+ ## Context Manager
62
+
63
+ ```python
64
+ import contextlib
65
+
66
+ @contextlib.contextmanager
67
+ def tracked(am, tool_name, **kwargs):
68
+ span = am.track(tool_name, **kwargs)
69
+ try:
70
+ yield span
71
+ except Exception as e:
72
+ span.finish(ok=False, error=str(e))
73
+ raise
74
+ else:
75
+ span.finish()
76
+
77
+ # Usage
78
+ with tracked(am, "database_query", model="gpt-4o") as span:
79
+ results = db.execute(sql)
80
+ span.estimated_input_tokens = len(sql) // 4
81
+ ```
82
+
83
+ ## Conversations & Tasks
84
+
85
+ Group spans into conversations (sessions) and tasks:
86
+
87
+ ```python
88
+ span = am.track(
89
+ "generate_code",
90
+ model="claude-sonnet-4-20250514",
91
+ conversation_id="conv-abc123",
92
+ task_id="implement-feature-x",
93
+ )
94
+ ```
95
+
96
+ ## Protocol
97
+
98
+ The SDK sends spans as OTLP-compatible JSON to `POST /v1/traces`. This is the
99
+ same format used by OpenTelemetry, making it compatible with any OTLP collector.
@@ -0,0 +1,7 @@
1
+ """Agent Meter Python SDK — track AI agent tool calls in 60 seconds."""
2
+
3
+ from agent_meter.client import AgentMeter
4
+ from agent_meter.types import ToolCall
5
+
6
+ __all__ = ["AgentMeter", "ToolCall"]
7
+ __version__ = "0.1.0"
@@ -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
+ }
@@ -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,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentmeter-obs"
7
+ version = "0.1.0"
8
+ description = "Lightweight SDK to track AI agent tool calls, costs, and latency."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "dnorio", email = "dnorio@users.noreply.github.com" }]
13
+ keywords = ["ai", "agents", "observability", "telemetry", "llm"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Libraries",
20
+ ]
21
+ dependencies = ["httpx>=0.24"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/dnorio/agent-meter"
25
+ Repository = "https://github.com/dnorio/agent-meter"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["agent_meter"]
File without changes
@@ -0,0 +1,69 @@
1
+ """Tests for agent_meter SDK."""
2
+
3
+ import json
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ from agent_meter import AgentMeter, ToolCall
7
+
8
+
9
+ def test_tool_call_finish():
10
+ tc = ToolCall(tool_name="test_tool")
11
+ assert tc.ok is True
12
+ tc.finish(ok=False, error="timeout")
13
+ assert tc.ok is False
14
+ assert tc.error == "timeout"
15
+ assert tc.duration_ms is not None
16
+ assert tc.duration_ms >= 0
17
+
18
+
19
+ def test_tool_call_to_dict():
20
+ tc = ToolCall(tool_name="search", model="gpt-4o")
21
+ tc.finish()
22
+ d = tc.to_dict()
23
+ assert d["tool_name"] == "search"
24
+ assert d["model"] == "gpt-4o"
25
+ assert d["ok"] is True
26
+ assert "duration_ms" in d
27
+
28
+
29
+ def test_client_track():
30
+ am = AgentMeter(api_key="test", endpoint="http://localhost:3000", ide="python-sdk", flush_interval=999)
31
+ span = am.track("my_tool", model="gpt-4o")
32
+ assert span.tool_name == "my_tool"
33
+ assert span.ide == "python-sdk"
34
+ span.finish()
35
+ am._closed = True # prevent flush timer
36
+ if am._timer:
37
+ am._timer.cancel()
38
+
39
+
40
+ def test_client_build_payload():
41
+ am = AgentMeter(api_key="test", endpoint="http://localhost:3000", flush_interval=999)
42
+ span = ToolCall(tool_name="code_gen", model="claude-sonnet-4-20250514")
43
+ span.finish()
44
+ payload = am._build_otlp_payload([span])
45
+ assert "resourceSpans" in payload
46
+ rs = payload["resourceSpans"][0]
47
+ assert rs["scopeSpans"][0]["spans"][0]["name"] == "code_gen"
48
+ am._closed = True
49
+ if am._timer:
50
+ am._timer.cancel()
51
+
52
+
53
+ @patch("agent_meter.client.httpx.post")
54
+ def test_flush_sends_to_server(mock_post):
55
+ mock_resp = MagicMock()
56
+ mock_resp.raise_for_status = MagicMock()
57
+ mock_post.return_value = mock_resp
58
+
59
+ am = AgentMeter(api_key="am_live_test", endpoint="http://localhost:3000", flush_interval=999)
60
+ span = am.track("tool_a")
61
+ span.finish()
62
+ sent = am.flush()
63
+ assert sent == 1
64
+ mock_post.assert_called_once()
65
+ call_kwargs = mock_post.call_args
66
+ assert "/v1/traces" in call_kwargs.args[0] or "/v1/traces" in str(call_kwargs)
67
+ am._closed = True
68
+ if am._timer:
69
+ am._timer.cancel()