a2ea-py 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.
- a2ea/__init__.py +11 -0
- a2ea/client.py +223 -0
- a2ea/decorators.py +80 -0
- a2ea_py-0.1.0.dist-info/METADATA +81 -0
- a2ea_py-0.1.0.dist-info/RECORD +7 -0
- a2ea_py-0.1.0.dist-info/WHEEL +5 -0
- a2ea_py-0.1.0.dist-info/top_level.txt +1 -0
a2ea/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Official Python SDK for the A2EA telemetry platform."""
|
|
2
|
+
|
|
3
|
+
from .client import A2EAClient, get_default_client, set_default_client
|
|
4
|
+
from .decorators import track
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"A2EAClient",
|
|
8
|
+
"get_default_client",
|
|
9
|
+
"set_default_client",
|
|
10
|
+
"track",
|
|
11
|
+
]
|
a2ea/client.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
LOGGER = logging.getLogger("a2ea.sdk")
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CLIENT: "A2EAClient | None" = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TelemetryJob:
|
|
25
|
+
event: dict[str, Any]
|
|
26
|
+
task_type: str
|
|
27
|
+
attempt: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_default_client(client: "A2EAClient") -> None:
|
|
31
|
+
global _DEFAULT_CLIENT
|
|
32
|
+
_DEFAULT_CLIENT = client
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_default_client() -> "A2EAClient | None":
|
|
36
|
+
return _DEFAULT_CLIENT
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default_base_url() -> str:
|
|
40
|
+
return os.getenv("A2EA_BASE_URL", "https://aimodularity.com")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_agent_id() -> str:
|
|
44
|
+
hostname = socket.gethostname().replace(".", "-")
|
|
45
|
+
return f"agent_{hostname}_{os.getpid()}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class A2EAClient:
|
|
49
|
+
"""Production-ready Python client for A2EA telemetry submission.
|
|
50
|
+
|
|
51
|
+
The client signs telemetry locally with the workspace API key, queues delivery in a
|
|
52
|
+
background thread, retries transient failures, and keeps a fallback buffer when the
|
|
53
|
+
transport path is unavailable.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str,
|
|
59
|
+
*,
|
|
60
|
+
base_url: Optional[str] = None,
|
|
61
|
+
agent_id: Optional[str] = None,
|
|
62
|
+
request_timeout: float = 5.0,
|
|
63
|
+
max_retries: int = 5,
|
|
64
|
+
retry_backoff_base_seconds: float = 0.75,
|
|
65
|
+
max_retry_delay_seconds: float = 30.0,
|
|
66
|
+
auto_set_default: bool = True,
|
|
67
|
+
) -> None:
|
|
68
|
+
if not api_key or not api_key.strip():
|
|
69
|
+
raise ValueError("api_key is required")
|
|
70
|
+
|
|
71
|
+
self.base_url = (base_url or _default_base_url()).rstrip("/")
|
|
72
|
+
self.agent_id = agent_id or _default_agent_id()
|
|
73
|
+
self.api_key = api_key.strip()
|
|
74
|
+
self.signing_secret = self.api_key.encode("utf-8")
|
|
75
|
+
self.signing_key_id = "workspace-api-key"
|
|
76
|
+
self.request_timeout = request_timeout
|
|
77
|
+
self.max_retries = max_retries
|
|
78
|
+
self.retry_backoff_base_seconds = retry_backoff_base_seconds
|
|
79
|
+
self.max_retry_delay_seconds = max_retry_delay_seconds
|
|
80
|
+
self.session = requests.Session()
|
|
81
|
+
self._queue: "queue.Queue[TelemetryJob]" = queue.Queue()
|
|
82
|
+
self._fallback_buffer: list[dict[str, Any]] = []
|
|
83
|
+
self._worker = threading.Thread(target=self._worker_loop, name="a2ea-worker", daemon=True)
|
|
84
|
+
self._worker.start()
|
|
85
|
+
|
|
86
|
+
if auto_set_default:
|
|
87
|
+
set_default_client(self)
|
|
88
|
+
|
|
89
|
+
def track_agent(self, *, task_type: str, agent_name: Optional[str] = None):
|
|
90
|
+
from .decorators import track
|
|
91
|
+
|
|
92
|
+
return track(agent_name=agent_name or self.agent_id, task_type=task_type, client=self)
|
|
93
|
+
|
|
94
|
+
def send_telemetry(
|
|
95
|
+
self,
|
|
96
|
+
task_type: str,
|
|
97
|
+
duration_ms: int,
|
|
98
|
+
outcome: str,
|
|
99
|
+
*,
|
|
100
|
+
event_id: Optional[str] = None,
|
|
101
|
+
timestamp: Optional[str] = None,
|
|
102
|
+
) -> Optional[str]:
|
|
103
|
+
event = self._build_event(
|
|
104
|
+
task_type=task_type,
|
|
105
|
+
duration_ms=duration_ms,
|
|
106
|
+
outcome=outcome,
|
|
107
|
+
event_id=event_id,
|
|
108
|
+
timestamp=timestamp,
|
|
109
|
+
)
|
|
110
|
+
self._queue.put(TelemetryJob(event=event, task_type=task_type))
|
|
111
|
+
return event["eventId"]
|
|
112
|
+
|
|
113
|
+
def get_local_fallback_buffer(self) -> list[dict[str, Any]]:
|
|
114
|
+
return list(self._fallback_buffer)
|
|
115
|
+
|
|
116
|
+
def flush(self, timeout: Optional[float] = None) -> None:
|
|
117
|
+
start = time.time()
|
|
118
|
+
while self._queue.unfinished_tasks:
|
|
119
|
+
if timeout is not None and (time.time() - start) > timeout:
|
|
120
|
+
raise TimeoutError("A2EA telemetry queue did not flush before timeout")
|
|
121
|
+
time.sleep(0.05)
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
self.session.close()
|
|
125
|
+
|
|
126
|
+
def _worker_loop(self) -> None:
|
|
127
|
+
while True:
|
|
128
|
+
job = self._queue.get()
|
|
129
|
+
try:
|
|
130
|
+
self._ship_event(job.event, job.task_type)
|
|
131
|
+
except Exception as exc: # pragma: no cover - intentional silent fallback boundary
|
|
132
|
+
LOGGER.warning(
|
|
133
|
+
"A2EA telemetry transport failed for %s (attempt %s/%s): %s",
|
|
134
|
+
job.event.get("eventId"),
|
|
135
|
+
job.attempt + 1,
|
|
136
|
+
self.max_retries,
|
|
137
|
+
exc,
|
|
138
|
+
)
|
|
139
|
+
self._handle_retry(job)
|
|
140
|
+
finally:
|
|
141
|
+
self._queue.task_done()
|
|
142
|
+
|
|
143
|
+
def _handle_retry(self, job: TelemetryJob) -> None:
|
|
144
|
+
next_attempt = job.attempt + 1
|
|
145
|
+
if next_attempt > self.max_retries:
|
|
146
|
+
LOGGER.warning(
|
|
147
|
+
"A2EA telemetry event %s exhausted retries; retaining in local fallback buffer",
|
|
148
|
+
job.event.get("eventId"),
|
|
149
|
+
)
|
|
150
|
+
self._fallback_buffer.append(job.event)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
delay = min(self.retry_backoff_base_seconds * (2 ** job.attempt), self.max_retry_delay_seconds)
|
|
154
|
+
timer = threading.Timer(
|
|
155
|
+
delay,
|
|
156
|
+
lambda: self._queue.put(TelemetryJob(event=job.event, task_type=job.task_type, attempt=next_attempt)),
|
|
157
|
+
)
|
|
158
|
+
timer.daemon = True
|
|
159
|
+
timer.start()
|
|
160
|
+
|
|
161
|
+
def _ship_event(self, event: dict[str, Any], task_type: str) -> None:
|
|
162
|
+
payload = {
|
|
163
|
+
"taskType": task_type,
|
|
164
|
+
"telemetry": [event],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
response = self.session.post(
|
|
168
|
+
f"{self.base_url}/api/v1/telemetry/score",
|
|
169
|
+
json=payload,
|
|
170
|
+
timeout=self.request_timeout,
|
|
171
|
+
headers={
|
|
172
|
+
"X-A2EA-API-Key": self.api_key,
|
|
173
|
+
"Content-Type": "application/json",
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
if response.status_code >= 300:
|
|
177
|
+
raise RuntimeError(f"telemetry score request failed with status {response.status_code}: {response.text}")
|
|
178
|
+
|
|
179
|
+
def _build_event(
|
|
180
|
+
self,
|
|
181
|
+
*,
|
|
182
|
+
task_type: str,
|
|
183
|
+
duration_ms: int,
|
|
184
|
+
outcome: str,
|
|
185
|
+
event_id: Optional[str],
|
|
186
|
+
timestamp: Optional[str],
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
normalized_outcome = "failure" if outcome == "failure" else "success"
|
|
189
|
+
event = {
|
|
190
|
+
"eventId": event_id or f"evt_{uuid.uuid4().hex}",
|
|
191
|
+
"agentId": self.agent_id,
|
|
192
|
+
"timestamp": timestamp or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
193
|
+
"durationMs": int(duration_ms),
|
|
194
|
+
"taskType": task_type,
|
|
195
|
+
"outcome": normalized_outcome,
|
|
196
|
+
}
|
|
197
|
+
signature = self._sign_payload(event)
|
|
198
|
+
event["verification"] = {
|
|
199
|
+
"keyId": self.signing_key_id,
|
|
200
|
+
"algorithm": "hmac_sha256",
|
|
201
|
+
"signature": signature,
|
|
202
|
+
}
|
|
203
|
+
return event
|
|
204
|
+
|
|
205
|
+
def _sign_payload(self, event: dict[str, Any]) -> str:
|
|
206
|
+
canonical = self._canonical_event_payload(event)
|
|
207
|
+
return hmac.new(self.signing_secret, canonical.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _canonical_event_payload(event: dict[str, Any]) -> str:
|
|
211
|
+
timestamp_value = str(event["timestamp"])
|
|
212
|
+
if timestamp_value.endswith("Z"):
|
|
213
|
+
timestamp_value = timestamp_value.replace("Z", "+00:00")
|
|
214
|
+
timestamp = datetime.fromisoformat(timestamp_value).astimezone(timezone.utc).isoformat()
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
f"event_id={event['eventId']}|"
|
|
218
|
+
f"agent_id={event['agentId']}|"
|
|
219
|
+
f"timestamp={timestamp}|"
|
|
220
|
+
f"duration_ms={event['durationMs']}|"
|
|
221
|
+
f"task_type={event['taskType']}|"
|
|
222
|
+
f"outcome={event['outcome']}"
|
|
223
|
+
)
|
a2ea/decorators.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any, Callable, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
from .client import get_default_client
|
|
10
|
+
|
|
11
|
+
LOGGER = logging.getLogger("a2ea.sdk")
|
|
12
|
+
|
|
13
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def track(*, agent_name: Optional[str] = None, task_type: str, client=None) -> Callable[[F], F]:
|
|
17
|
+
"""Framework-agnostic telemetry wrapper.
|
|
18
|
+
|
|
19
|
+
Captures only execution timing and success/failure metadata. It does not inspect
|
|
20
|
+
function arguments or private model payloads.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def decorator(func: F) -> F:
|
|
24
|
+
if asyncio.iscoroutinefunction(func):
|
|
25
|
+
|
|
26
|
+
@wraps(func)
|
|
27
|
+
async def async_wrapper(*args: Any, **kwargs: Any):
|
|
28
|
+
started = time.perf_counter()
|
|
29
|
+
selected_client = client or get_default_client()
|
|
30
|
+
try:
|
|
31
|
+
result = await func(*args, **kwargs)
|
|
32
|
+
if selected_client:
|
|
33
|
+
selected_client.send_telemetry(
|
|
34
|
+
task_type=task_type,
|
|
35
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
36
|
+
outcome="success",
|
|
37
|
+
)
|
|
38
|
+
return result
|
|
39
|
+
except Exception:
|
|
40
|
+
if selected_client:
|
|
41
|
+
try:
|
|
42
|
+
selected_client.send_telemetry(
|
|
43
|
+
task_type=task_type,
|
|
44
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
45
|
+
outcome="failure",
|
|
46
|
+
)
|
|
47
|
+
except Exception as exc: # pragma: no cover
|
|
48
|
+
LOGGER.warning("A2EA async telemetry fallback failed for %s: %s", agent_name or task_type, exc)
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
return async_wrapper # type: ignore[return-value]
|
|
52
|
+
|
|
53
|
+
@wraps(func)
|
|
54
|
+
def sync_wrapper(*args: Any, **kwargs: Any):
|
|
55
|
+
started = time.perf_counter()
|
|
56
|
+
selected_client = client or get_default_client()
|
|
57
|
+
try:
|
|
58
|
+
result = func(*args, **kwargs)
|
|
59
|
+
if selected_client:
|
|
60
|
+
selected_client.send_telemetry(
|
|
61
|
+
task_type=task_type,
|
|
62
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
63
|
+
outcome="success",
|
|
64
|
+
)
|
|
65
|
+
return result
|
|
66
|
+
except Exception:
|
|
67
|
+
if selected_client:
|
|
68
|
+
try:
|
|
69
|
+
selected_client.send_telemetry(
|
|
70
|
+
task_type=task_type,
|
|
71
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
72
|
+
outcome="failure",
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc: # pragma: no cover
|
|
75
|
+
LOGGER.warning("A2EA sync telemetry fallback failed for %s: %s", agent_name or task_type, exc)
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
return sync_wrapper # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
return decorator
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: a2ea-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the A2EA telemetry and attribution platform.
|
|
5
|
+
Author: AI Blockchain Ventures
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.31.0
|
|
9
|
+
|
|
10
|
+
# a2ea-py
|
|
11
|
+
|
|
12
|
+
Official Python SDK for sending A2EA telemetry to the backend.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install a2ea-py
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quickstart
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from a2ea import A2EAClient
|
|
24
|
+
|
|
25
|
+
client = A2EAClient(
|
|
26
|
+
api_key="your X-A2EA-API-Key"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@client.track_agent(task_type="data_processing")
|
|
31
|
+
def run_agent(payload):
|
|
32
|
+
return process(payload)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Optional configuration
|
|
36
|
+
|
|
37
|
+
- `base_url`
|
|
38
|
+
- Defaults to `https://aimodularity.com`
|
|
39
|
+
- Set this when testing locally against `http://127.0.0.1:4080`
|
|
40
|
+
- `agent_id`
|
|
41
|
+
- Optional override if you want to assign a fixed agent identifier instead of the default host/process-based value
|
|
42
|
+
|
|
43
|
+
Example for local development:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from a2ea import A2EAClient
|
|
47
|
+
|
|
48
|
+
client = A2EAClient(
|
|
49
|
+
api_key="your X-A2EA-API-Key",
|
|
50
|
+
base_url="http://127.0.0.1:4080",
|
|
51
|
+
agent_id="agent_pipeline_014",
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What the client sends
|
|
56
|
+
|
|
57
|
+
Each tracked function call produces:
|
|
58
|
+
|
|
59
|
+
- `eventId`
|
|
60
|
+
- `agentId`
|
|
61
|
+
- `timestamp`
|
|
62
|
+
- `durationMs`
|
|
63
|
+
- `taskType`
|
|
64
|
+
- `outcome`
|
|
65
|
+
- `verification`
|
|
66
|
+
|
|
67
|
+
The verification payload is signed locally with HMAC-SHA256 using the workspace API key so the backend can verify the telemetry envelope.
|
|
68
|
+
|
|
69
|
+
## Reliability behavior
|
|
70
|
+
|
|
71
|
+
- submission runs in a background thread
|
|
72
|
+
- transient failures are retried with backoff
|
|
73
|
+
- exhausted events are retained in a local fallback buffer
|
|
74
|
+
|
|
75
|
+
## Local package test commands
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m unittest discover tests
|
|
79
|
+
python -m build
|
|
80
|
+
pip install dist/a2ea_py-0.1.0-py3-none-any.whl
|
|
81
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
a2ea/__init__.py,sha256=-NaIVonjIwfexKJBn_ebOjP6aA4ZyAWTD5pGhpF8SAo,259
|
|
2
|
+
a2ea/client.py,sha256=81aRoHEM90a763yKJnmUAZTDLcfpNz3KzXucIEytdAM,7481
|
|
3
|
+
a2ea/decorators.py,sha256=9rrX9BngkaaS4awgG3e2aKDUR2r36W-0kK2Nsq30fW8,3127
|
|
4
|
+
a2ea_py-0.1.0.dist-info/METADATA,sha256=yVG1USUeYqugrjxUTrWn4XuiOSMsFivumglzIjWtous,1780
|
|
5
|
+
a2ea_py-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
a2ea_py-0.1.0.dist-info/top_level.txt,sha256=dZP6uZHNRs2dE5AMlwt4JmDlVSAVh_Mj08ZVaRlcLqI,5
|
|
7
|
+
a2ea_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
a2ea
|