computer-agent-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.
- computer_agent_py-0.1.0.dist-info/METADATA +307 -0
- computer_agent_py-0.1.0.dist-info/RECORD +25 -0
- computer_agent_py-0.1.0.dist-info/WHEEL +4 -0
- computeragent/__init__.py +90 -0
- computeragent/_proxy/__init__.py +8 -0
- computeragent/_proxy/client.py +225 -0
- computeragent/_proxy/query.py +165 -0
- computeragent/policy/__init__.py +59 -0
- computeragent/policy/authorizer.py +161 -0
- computeragent/policy/cedar.py +182 -0
- computeragent/policy/opa.py +124 -0
- computeragent/policy/types.py +121 -0
- computeragent/py.typed +0 -0
- computeragent/telemetry/__init__.py +24 -0
- computeragent/telemetry/config.py +176 -0
- computeragent/telemetry/event.py +355 -0
- computeragent/telemetry/middleware/__init__.py +8 -0
- computeragent/telemetry/middleware/guardrails.py +127 -0
- computeragent/telemetry/middleware/pii.py +158 -0
- computeragent/telemetry/pipeline.py +160 -0
- computeragent/telemetry/sinks/__init__.py +28 -0
- computeragent/telemetry/sinks/agentos.py +442 -0
- computeragent/telemetry/sinks/message_archive.py +136 -0
- computeragent/telemetry/sinks/otel.py +375 -0
- computeragent/types.py +13 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""``GuardrailFilter`` — generic transform/drop middleware.
|
|
2
|
+
|
|
3
|
+
Capabilities:
|
|
4
|
+
|
|
5
|
+
* truncate over-long string values in payload (``max_attribute_length``)
|
|
6
|
+
* drop tool-related events for tool names outside an allowlist (glob-aware)
|
|
7
|
+
* enforce a running per-session cost ceiling
|
|
8
|
+
* invoke a user-supplied async content filter
|
|
9
|
+
|
|
10
|
+
The cost ceiling is enforced *across events* by tracking per-session running
|
|
11
|
+
cost in a small in-memory map. The map shrinks on each ``session_ended`` so it
|
|
12
|
+
doesn't grow without bound.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import logging
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ..event import TelemetryEvent
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("computeragent.telemetry")
|
|
26
|
+
|
|
27
|
+
ContentFilter = Callable[["TelemetryEvent"], Awaitable["TelemetryEvent | None"]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GuardrailFilter:
|
|
31
|
+
"""Apply size, scope, and cost guardrails before events reach sinks."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
max_attribute_length: int | None = 8192,
|
|
37
|
+
tool_name_allowlist: set[str] | None = None,
|
|
38
|
+
cost_ceiling_usd: float | None = None,
|
|
39
|
+
content_filter: ContentFilter | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.max_attribute_length = max_attribute_length
|
|
42
|
+
self.tool_name_allowlist = tool_name_allowlist
|
|
43
|
+
self.cost_ceiling_usd = cost_ceiling_usd
|
|
44
|
+
self.content_filter = content_filter
|
|
45
|
+
# session_id → running cost. Cleared on session_ended.
|
|
46
|
+
self._cost_by_session: dict[str, float] = {}
|
|
47
|
+
# Sessions that have already tripped the ceiling — drop further events
|
|
48
|
+
# from them until session_ended.
|
|
49
|
+
self._tripped: set[str] = set()
|
|
50
|
+
|
|
51
|
+
async def __call__(self, event: TelemetryEvent) -> TelemetryEvent | None:
|
|
52
|
+
# Remember the original kind so cleanup logic at the end can tell a
|
|
53
|
+
# real session_ended apart from a synthetic one we minted here.
|
|
54
|
+
original_kind = event.kind
|
|
55
|
+
synthesized_session_ended = False
|
|
56
|
+
|
|
57
|
+
# 1. Drop entirely if this session has already tripped the budget.
|
|
58
|
+
if event.session_id in self._tripped and event.kind != "session_ended":
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# 2. Tool-name allowlist: only applies to tool_use / tool_result.
|
|
62
|
+
if self.tool_name_allowlist is not None and event.kind in {"tool_use", "tool_result"}:
|
|
63
|
+
name = event.payload.get("tool_name") or event.payload.get("tool_use_id") or ""
|
|
64
|
+
if not _matches_any(name, self.tool_name_allowlist):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# 3. Cost ceiling. usage_snapshot carries the running total cost.
|
|
68
|
+
if self.cost_ceiling_usd is not None and event.kind == "usage_snapshot":
|
|
69
|
+
cost = float(event.payload.get("total_cost_usd", 0.0) or 0.0)
|
|
70
|
+
self._cost_by_session[event.session_id] = cost
|
|
71
|
+
if cost > self.cost_ceiling_usd:
|
|
72
|
+
self._tripped.add(event.session_id)
|
|
73
|
+
# Transform this event into a synthetic session_ended so a
|
|
74
|
+
# sink consumer still sees a terminal event, then forward it.
|
|
75
|
+
event.kind = "session_ended"
|
|
76
|
+
event.payload = {
|
|
77
|
+
"is_error": True,
|
|
78
|
+
"subtype": "budget_exceeded",
|
|
79
|
+
"total_cost_usd": cost,
|
|
80
|
+
"cost_ceiling_usd": self.cost_ceiling_usd,
|
|
81
|
+
"result": "",
|
|
82
|
+
"num_turns": 0,
|
|
83
|
+
"duration_ms": 0.0,
|
|
84
|
+
"usage": event.payload.get("usage", {}),
|
|
85
|
+
}
|
|
86
|
+
synthesized_session_ended = True
|
|
87
|
+
|
|
88
|
+
# 4. Truncate long strings in payload (cheap; modifies in place).
|
|
89
|
+
if self.max_attribute_length is not None:
|
|
90
|
+
truncated = _truncate(event.payload, self.max_attribute_length)
|
|
91
|
+
assert isinstance(truncated, dict) # _truncate preserves shape for dict in → dict out
|
|
92
|
+
event.payload = truncated
|
|
93
|
+
|
|
94
|
+
# 5. User-supplied filter — last call so it sees the final payload.
|
|
95
|
+
if self.content_filter is not None:
|
|
96
|
+
try:
|
|
97
|
+
filtered: TelemetryEvent | None = await self.content_filter(event)
|
|
98
|
+
if filtered is None:
|
|
99
|
+
return None
|
|
100
|
+
event = filtered
|
|
101
|
+
except Exception: # noqa: BLE001
|
|
102
|
+
logger.warning("content_filter raised; dropping event", exc_info=True)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# 6. session_ended bookkeeping — only on a REAL terminal event.
|
|
106
|
+
# A synthetic session_ended we minted from the trip must keep
|
|
107
|
+
# _tripped state set, so further events from that session are dropped.
|
|
108
|
+
if original_kind == "session_ended" and not synthesized_session_ended:
|
|
109
|
+
self._cost_by_session.pop(event.session_id, None)
|
|
110
|
+
self._tripped.discard(event.session_id)
|
|
111
|
+
|
|
112
|
+
return event
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _matches_any(name: str, allowlist: set[str]) -> bool:
|
|
116
|
+
"""Glob match — ``mcp__*`` and ``Read``-style literals both supported."""
|
|
117
|
+
return any(fnmatch.fnmatchcase(name, pat) for pat in allowlist)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _truncate(value: object, limit: int) -> object:
|
|
121
|
+
if isinstance(value, dict):
|
|
122
|
+
return {k: _truncate(v, limit) for k, v in value.items()}
|
|
123
|
+
if isinstance(value, list):
|
|
124
|
+
return [_truncate(v, limit) for v in value]
|
|
125
|
+
if isinstance(value, str) and len(value) > limit:
|
|
126
|
+
return value[:limit] + f"…(+{len(value) - limit} chars)"
|
|
127
|
+
return value
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""``PiiRedactor`` — pattern-based redaction over ``event.payload``.
|
|
2
|
+
|
|
3
|
+
Built-in patterns cover the most common shapes we don't want shipping to a
|
|
4
|
+
remote observability backend by default: email, US phone, SSN, generic
|
|
5
|
+
credit-card-shaped digit runs, and AWS access key IDs / secret keys. The user
|
|
6
|
+
appends their own patterns via ``extra_patterns=`` and steers redaction by
|
|
7
|
+
path with ``path_allowlist=`` / ``path_denylist=``.
|
|
8
|
+
|
|
9
|
+
Fail-closed: a bad pattern or an exception inside the substitution drops the
|
|
10
|
+
event rather than forwarding possibly-unredacted content.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..event import TelemetryEvent
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("computeragent.telemetry")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Conservative built-in patterns — high precision, low recall. Users add their
|
|
27
|
+
# own (badge IDs, internal account numbers, …) via extra_patterns.
|
|
28
|
+
BUILTIN_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
29
|
+
# email — RFC 5322 simplified
|
|
30
|
+
re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"),
|
|
31
|
+
# US phone with optional country code; matches "+1 555-123-4567",
|
|
32
|
+
# "(555) 123-4567", "555.123.4567", etc.
|
|
33
|
+
re.compile(r"(?:\+?1[\s\-.])?\(?\d{3}\)?[\s\-.]\d{3}[\s\-.]\d{4}"),
|
|
34
|
+
# US SSN
|
|
35
|
+
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
36
|
+
# Credit card shaped — 13–19 digit runs with optional separators.
|
|
37
|
+
re.compile(r"\b(?:\d[ \-]?){13,19}\b"),
|
|
38
|
+
# AWS access key ID
|
|
39
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Strategy = Literal["mask", "hash", "drop"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PiiRedactor:
|
|
47
|
+
"""Walks ``event.payload`` and redacts matches in string values.
|
|
48
|
+
|
|
49
|
+
The walk descends into nested dicts and lists; tuples/sets are converted
|
|
50
|
+
to lists during processing then put back. Non-string leaves are left
|
|
51
|
+
alone. ``raw`` is never touched — sinks that need verbatim data can still
|
|
52
|
+
grab it, but content-shipping sinks (OTel, AgentOS) only read ``payload``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
strategy: Strategy = "mask",
|
|
59
|
+
extra_patterns: list[re.Pattern[str] | str] | None = None,
|
|
60
|
+
path_allowlist: list[str] | None = None,
|
|
61
|
+
path_denylist: list[str] | None = None,
|
|
62
|
+
include_builtins: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.strategy = strategy
|
|
65
|
+
self._patterns: list[re.Pattern[str]] = []
|
|
66
|
+
if include_builtins:
|
|
67
|
+
self._patterns.extend(BUILTIN_PATTERNS)
|
|
68
|
+
for p in extra_patterns or []:
|
|
69
|
+
self._patterns.append(p if isinstance(p, re.Pattern) else re.compile(p))
|
|
70
|
+
self._allowlist = [p.strip(".") for p in (path_allowlist or [])]
|
|
71
|
+
self._denylist = [p.strip(".") for p in (path_denylist or [])]
|
|
72
|
+
|
|
73
|
+
async def __call__(self, event: TelemetryEvent) -> TelemetryEvent | None:
|
|
74
|
+
try:
|
|
75
|
+
event.payload = self._walk(event.payload, path="payload")
|
|
76
|
+
except Exception: # noqa: BLE001
|
|
77
|
+
logger.warning("PiiRedactor failed; dropping event (fail-closed)", exc_info=True)
|
|
78
|
+
return None
|
|
79
|
+
return event
|
|
80
|
+
|
|
81
|
+
# ── walk ----------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _walk(self, value: Any, *, path: str) -> Any:
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
return {k: self._walk(v, path=f"{path}.{k}") for k, v in value.items()}
|
|
86
|
+
if isinstance(value, list):
|
|
87
|
+
return [self._walk(v, path=f"{path}[{i}]") for i, v in enumerate(value)]
|
|
88
|
+
if isinstance(value, tuple):
|
|
89
|
+
return tuple(self._walk(v, path=f"{path}[{i}]") for i, v in enumerate(value))
|
|
90
|
+
if isinstance(value, str):
|
|
91
|
+
return self._redact_if_applicable(value, path=path)
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
# ── policy --------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _redact_if_applicable(self, value: str, *, path: str) -> str:
|
|
97
|
+
bare_path = path.removeprefix("payload.").removeprefix("payload")
|
|
98
|
+
# Denylist wins outright — force redaction (or drop the field entirely
|
|
99
|
+
# when strategy=drop, but at this layer we operate on the string value
|
|
100
|
+
# so 'drop' on a string is equivalent to empty string).
|
|
101
|
+
if any(_path_matches(bare_path, p) for p in self._denylist):
|
|
102
|
+
return self._apply_strategy(value, force=True)
|
|
103
|
+
# Allowlist short-circuits redaction — useful for whitelisted fields
|
|
104
|
+
# like a known-safe URL or filename.
|
|
105
|
+
if self._allowlist and any(_path_matches(bare_path, p) for p in self._allowlist):
|
|
106
|
+
return value
|
|
107
|
+
return self._apply_strategy(value, force=False)
|
|
108
|
+
|
|
109
|
+
def _apply_strategy(self, value: str, *, force: bool) -> str:
|
|
110
|
+
if force and self.strategy == "drop":
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
if self.strategy == "drop":
|
|
114
|
+
# In drop mode, if any pattern matches anywhere, return empty.
|
|
115
|
+
for pat in self._patterns:
|
|
116
|
+
if pat.search(value):
|
|
117
|
+
return ""
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
def replace(match: re.Match[str]) -> str:
|
|
121
|
+
captured = match.group(0)
|
|
122
|
+
if self.strategy == "hash":
|
|
123
|
+
digest = hashlib.sha256(captured.encode("utf-8")).hexdigest()[:12]
|
|
124
|
+
return f"<redacted:{digest}>"
|
|
125
|
+
return "****"
|
|
126
|
+
|
|
127
|
+
out = value
|
|
128
|
+
for pat in self._patterns:
|
|
129
|
+
try:
|
|
130
|
+
out = pat.sub(replace, out)
|
|
131
|
+
except re.error:
|
|
132
|
+
logger.warning("regex sub failed for pattern %r; skipping", pat.pattern)
|
|
133
|
+
# If the path is force-redacted but no pattern matched, return a
|
|
134
|
+
# blanket replacement so the field's content never leaks.
|
|
135
|
+
if force and out == value:
|
|
136
|
+
if self.strategy == "hash":
|
|
137
|
+
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
|
|
138
|
+
return f"<redacted:{digest}>"
|
|
139
|
+
return "****"
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _path_matches(path: str, pattern: str) -> bool:
|
|
144
|
+
"""Naive glob-ish path matcher: supports literal segments and ``*``.
|
|
145
|
+
|
|
146
|
+
Matches ``payload.foo.bar`` against ``foo.bar``, ``foo.*``, ``*.bar``.
|
|
147
|
+
Not a full glob — intentionally simple so the rule semantics are obvious.
|
|
148
|
+
"""
|
|
149
|
+
if pattern == "*":
|
|
150
|
+
return True
|
|
151
|
+
path_parts = path.split(".")
|
|
152
|
+
pat_parts = pattern.split(".")
|
|
153
|
+
if len(pat_parts) > len(path_parts):
|
|
154
|
+
return False
|
|
155
|
+
for pat, seg in zip(pat_parts, path_parts, strict=False):
|
|
156
|
+
if pat != "*" and pat != seg:
|
|
157
|
+
return False
|
|
158
|
+
return True
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The telemetry pipeline — middleware then fan-out to sinks.
|
|
2
|
+
|
|
3
|
+
Design properties:
|
|
4
|
+
|
|
5
|
+
* **Never breaks an agent run.** Every middleware and every sink is wrapped
|
|
6
|
+
with broad exception handling; failures are logged and absorbed.
|
|
7
|
+
* **Fail-closed for PII.** A middleware that raises drops the event (rather
|
|
8
|
+
than forwarding a possibly-unredacted one). Switchable via
|
|
9
|
+
``fail_open=True`` for debug builds.
|
|
10
|
+
* **Non-blocking sink emission.** Sinks run as background tasks so a slow
|
|
11
|
+
exporter never stalls the message hot path. ``flush()`` waits for them at
|
|
12
|
+
teardown.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import weakref
|
|
20
|
+
from typing import TYPE_CHECKING, Protocol
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Awaitable
|
|
24
|
+
|
|
25
|
+
from .event import TelemetryEvent
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("computeragent.telemetry")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TelemetryMiddleware(Protocol):
|
|
31
|
+
"""Transform or drop an event. Return None to drop."""
|
|
32
|
+
|
|
33
|
+
async def __call__(self, event: TelemetryEvent) -> TelemetryEvent | None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TelemetrySink(Protocol):
|
|
37
|
+
"""Write an event to an external system."""
|
|
38
|
+
|
|
39
|
+
async def emit(self, event: TelemetryEvent) -> None: ...
|
|
40
|
+
|
|
41
|
+
async def flush(self, timeout: float = 5.0) -> None: ...
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Pipeline:
|
|
47
|
+
"""Owns the middleware chain and sink fan-out.
|
|
48
|
+
|
|
49
|
+
A single ``Pipeline`` is normally enough per process; ``configure()`` builds
|
|
50
|
+
the module-level default. Tests construct their own.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
middleware: list[TelemetryMiddleware] | None = None,
|
|
57
|
+
sinks: list[TelemetrySink] | None = None,
|
|
58
|
+
fail_open: bool = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._middleware: list[TelemetryMiddleware] = list(middleware or [])
|
|
61
|
+
self._sinks: list[TelemetrySink] = list(sinks or [])
|
|
62
|
+
self._fail_open = fail_open
|
|
63
|
+
# Track scheduled sink tasks so flush() can await them.
|
|
64
|
+
self._pending: weakref.WeakSet[asyncio.Task[None]] = weakref.WeakSet()
|
|
65
|
+
|
|
66
|
+
# ── configuration -------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def add_middleware(self, mw: TelemetryMiddleware) -> None:
|
|
69
|
+
self._middleware.append(mw)
|
|
70
|
+
|
|
71
|
+
def add_sink(self, sink: TelemetrySink) -> None:
|
|
72
|
+
self._sinks.append(sink)
|
|
73
|
+
|
|
74
|
+
def replace_middleware(self, mws: list[TelemetryMiddleware]) -> None:
|
|
75
|
+
self._middleware = list(mws)
|
|
76
|
+
|
|
77
|
+
def replace_sinks(self, sinks: list[TelemetrySink]) -> None:
|
|
78
|
+
self._sinks = list(sinks)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def middleware(self) -> list[TelemetryMiddleware]:
|
|
82
|
+
return list(self._middleware)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def sinks(self) -> list[TelemetrySink]:
|
|
86
|
+
return list(self._sinks)
|
|
87
|
+
|
|
88
|
+
# ── runtime -------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def emit(self, event: TelemetryEvent) -> None:
|
|
91
|
+
"""Run middleware, then schedule sink emissions concurrently.
|
|
92
|
+
|
|
93
|
+
Returns as soon as scheduling is done; sink writes happen in the
|
|
94
|
+
background. Use :meth:`flush` to wait for them.
|
|
95
|
+
"""
|
|
96
|
+
for mw in self._middleware:
|
|
97
|
+
try:
|
|
98
|
+
event_or_none = await mw(event)
|
|
99
|
+
except Exception: # noqa: BLE001 - middleware must never break the agent
|
|
100
|
+
logger.warning(
|
|
101
|
+
"telemetry middleware raised; %s",
|
|
102
|
+
"forwarding event (fail_open=True)" if self._fail_open else "dropping event",
|
|
103
|
+
exc_info=True,
|
|
104
|
+
)
|
|
105
|
+
if not self._fail_open:
|
|
106
|
+
return
|
|
107
|
+
event_or_none = event
|
|
108
|
+
if event_or_none is None:
|
|
109
|
+
return
|
|
110
|
+
event = event_or_none
|
|
111
|
+
|
|
112
|
+
for sink in self._sinks:
|
|
113
|
+
task = asyncio.create_task(self._safe_emit(sink, event))
|
|
114
|
+
self._pending.add(task)
|
|
115
|
+
|
|
116
|
+
async def _safe_emit(self, sink: TelemetrySink, event: TelemetryEvent) -> None:
|
|
117
|
+
try:
|
|
118
|
+
await sink.emit(event)
|
|
119
|
+
except Exception: # noqa: BLE001 - sinks must never break the agent
|
|
120
|
+
logger.warning("telemetry sink raised on emit (%s)", type(sink).__name__, exc_info=True)
|
|
121
|
+
|
|
122
|
+
async def flush(self, timeout: float = 5.0) -> None:
|
|
123
|
+
"""Wait for outstanding sink writes, then flush each sink."""
|
|
124
|
+
# Snapshot the pending tasks; new ones added during await are picked up
|
|
125
|
+
# by sinks' own flush().
|
|
126
|
+
tasks: list[asyncio.Task[None]] = [t for t in self._pending if not t.done()]
|
|
127
|
+
if tasks:
|
|
128
|
+
try:
|
|
129
|
+
await asyncio.wait_for(
|
|
130
|
+
asyncio.gather(*tasks, return_exceptions=True), timeout=timeout
|
|
131
|
+
)
|
|
132
|
+
except TimeoutError:
|
|
133
|
+
logger.warning(
|
|
134
|
+
"telemetry flush timed out after %.1fs; %d sink tasks still pending",
|
|
135
|
+
timeout,
|
|
136
|
+
len([t for t in tasks if not t.done()]),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for sink in self._sinks:
|
|
140
|
+
try:
|
|
141
|
+
await sink.flush(timeout=timeout)
|
|
142
|
+
except Exception: # noqa: BLE001
|
|
143
|
+
logger.warning(
|
|
144
|
+
"telemetry sink raised on flush (%s)", type(sink).__name__, exc_info=True
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def aclose(self) -> None:
|
|
148
|
+
await self.flush()
|
|
149
|
+
for sink in self._sinks:
|
|
150
|
+
try:
|
|
151
|
+
await sink.aclose()
|
|
152
|
+
except Exception: # noqa: BLE001
|
|
153
|
+
logger.warning(
|
|
154
|
+
"telemetry sink raised on aclose (%s)", type(sink).__name__, exc_info=True
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _await(awaitable: Awaitable[None]) -> Awaitable[None]:
|
|
159
|
+
"""Type narrower for the linter (kept for future use)."""
|
|
160
|
+
return awaitable
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Telemetry sinks — OTel collector and AgentOS Mongo registry.
|
|
2
|
+
|
|
3
|
+
Each sink is conditional on its extra. Import the sink class only after
|
|
4
|
+
installing the corresponding extra; otherwise the import raises ``ImportError``
|
|
5
|
+
with a clean install hint.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["AgentRegistrySink", "MongoMessageSink", "OtelSink"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str): # type: ignore[no-untyped-def]
|
|
14
|
+
"""Lazy import so ``from .sinks import OtelSink`` raises a clean ImportError
|
|
15
|
+
only when the optional dep is actually missing."""
|
|
16
|
+
if name == "OtelSink":
|
|
17
|
+
from .otel import OtelSink as _OtelSink
|
|
18
|
+
|
|
19
|
+
return _OtelSink
|
|
20
|
+
if name == "AgentRegistrySink":
|
|
21
|
+
from .agentos import AgentRegistrySink as _AgentRegistrySink
|
|
22
|
+
|
|
23
|
+
return _AgentRegistrySink
|
|
24
|
+
if name == "MongoMessageSink":
|
|
25
|
+
from .message_archive import MongoMessageSink as _MongoMessageSink
|
|
26
|
+
|
|
27
|
+
return _MongoMessageSink
|
|
28
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|