dnorio-agent-meter 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.

Potentially problematic release.


This version of dnorio-agent-meter might be problematic. Click here for more details.

@@ -0,0 +1 @@
1
+ __pycache__/
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: dnorio-agent-meter
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
+ pip install agentmeter-obs
27
+
28
+ # From source:
29
+ cd sdk/python && python3 -m pip install -e .
30
+ ```
31
+
32
+ ## Quick Start (60 seconds)
33
+
34
+ ```python
35
+ from agent_meter import AgentMeter
36
+
37
+ # Initialize (picks up AGENT_METER_API_KEY from env)
38
+ am = AgentMeter(api_key="am_live_...")
39
+
40
+ # Track a tool call
41
+ span = am.track("web_search", model="gpt-4o")
42
+ result = do_web_search(query)
43
+ span.finish()
44
+
45
+ # Track with tokens/cost
46
+ span = am.track("code_review", model="claude-sonnet-4-20250514")
47
+ span.estimated_input_tokens = 2000
48
+ span.estimated_output_tokens = 500
49
+ span.usd_cost = 0.012
50
+ span.finish()
51
+
52
+ # Flush happens automatically every 5s, or on exit
53
+ # Force flush:
54
+ am.flush()
55
+ ```
56
+
57
+ ## Configuration
58
+
59
+ | Env Variable | Description | Default |
60
+ |---|---|---|
61
+ | `AGENT_METER_API_KEY` | API key (`am_live_...`) | — |
62
+ | `AGENT_METER_ENDPOINT` | Server URL | `http://localhost:8081` |
63
+ | `AGENT_METER_IDE` | IDE identifier | `python-sdk` |
64
+ | `AGENT_METER_AGENT` | Agent name | — |
65
+
66
+ Or pass directly:
67
+
68
+ ```python
69
+ am = AgentMeter(
70
+ api_key="am_live_...",
71
+ endpoint="http://localhost:3000",
72
+ ide="my-custom-agent",
73
+ agent="research-bot",
74
+ )
75
+ ```
76
+
77
+ ## Context Manager
78
+
79
+ ```python
80
+ import contextlib
81
+
82
+ @contextlib.contextmanager
83
+ def tracked(am, tool_name, **kwargs):
84
+ span = am.track(tool_name, **kwargs)
85
+ try:
86
+ yield span
87
+ except Exception as e:
88
+ span.finish(ok=False, error=str(e))
89
+ raise
90
+ else:
91
+ span.finish()
92
+
93
+ # Usage
94
+ with tracked(am, "database_query", model="gpt-4o") as span:
95
+ results = db.execute(sql)
96
+ span.estimated_input_tokens = len(sql) // 4
97
+ ```
98
+
99
+ ## Conversations & Tasks
100
+
101
+ Group spans into conversations (sessions) and tasks:
102
+
103
+ ```python
104
+ span = am.track(
105
+ "generate_code",
106
+ model="claude-sonnet-4-20250514",
107
+ conversation_id="conv-abc123",
108
+ task_id="implement-feature-x",
109
+ )
110
+ ```
111
+
112
+ ## Protocol
113
+
114
+ The SDK sends spans as OTLP-compatible JSON to `POST /v1/traces`. This is the
115
+ same format used by OpenTelemetry, making it compatible with any OTLP collector.
@@ -0,0 +1,97 @@
1
+ # agent-meter
2
+
3
+ Lightweight Python SDK to track AI agent tool calls, costs, and latency.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agentmeter-obs
9
+
10
+ # From source:
11
+ cd sdk/python && python3 -m pip install -e .
12
+ ```
13
+
14
+ ## Quick Start (60 seconds)
15
+
16
+ ```python
17
+ from agent_meter import AgentMeter
18
+
19
+ # Initialize (picks up AGENT_METER_API_KEY from env)
20
+ am = AgentMeter(api_key="am_live_...")
21
+
22
+ # Track a tool call
23
+ span = am.track("web_search", model="gpt-4o")
24
+ result = do_web_search(query)
25
+ span.finish()
26
+
27
+ # Track with tokens/cost
28
+ span = am.track("code_review", model="claude-sonnet-4-20250514")
29
+ span.estimated_input_tokens = 2000
30
+ span.estimated_output_tokens = 500
31
+ span.usd_cost = 0.012
32
+ span.finish()
33
+
34
+ # Flush happens automatically every 5s, or on exit
35
+ # Force flush:
36
+ am.flush()
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ | Env Variable | Description | Default |
42
+ |---|---|---|
43
+ | `AGENT_METER_API_KEY` | API key (`am_live_...`) | — |
44
+ | `AGENT_METER_ENDPOINT` | Server URL | `http://localhost:8081` |
45
+ | `AGENT_METER_IDE` | IDE identifier | `python-sdk` |
46
+ | `AGENT_METER_AGENT` | Agent name | — |
47
+
48
+ Or pass directly:
49
+
50
+ ```python
51
+ am = AgentMeter(
52
+ api_key="am_live_...",
53
+ endpoint="http://localhost:3000",
54
+ ide="my-custom-agent",
55
+ agent="research-bot",
56
+ )
57
+ ```
58
+
59
+ ## Context Manager
60
+
61
+ ```python
62
+ import contextlib
63
+
64
+ @contextlib.contextmanager
65
+ def tracked(am, tool_name, **kwargs):
66
+ span = am.track(tool_name, **kwargs)
67
+ try:
68
+ yield span
69
+ except Exception as e:
70
+ span.finish(ok=False, error=str(e))
71
+ raise
72
+ else:
73
+ span.finish()
74
+
75
+ # Usage
76
+ with tracked(am, "database_query", model="gpt-4o") as span:
77
+ results = db.execute(sql)
78
+ span.estimated_input_tokens = len(sql) // 4
79
+ ```
80
+
81
+ ## Conversations & Tasks
82
+
83
+ Group spans into conversations (sessions) and tasks:
84
+
85
+ ```python
86
+ span = am.track(
87
+ "generate_code",
88
+ model="claude-sonnet-4-20250514",
89
+ conversation_id="conv-abc123",
90
+ task_id="implement-feature-x",
91
+ )
92
+ ```
93
+
94
+ ## Protocol
95
+
96
+ The SDK sends spans as OTLP-compatible JSON to `POST /v1/traces`. This is the
97
+ 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 = "dnorio-agent-meter"
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()