agentrail-python 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.
@@ -0,0 +1,236 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """Bounded, best-effort batch delivery on one background thread per signal.
3
+
4
+ Capacity counts the batch being exported. One batch is exported at a time; the
5
+ OTLP exporter owns retries within its timeout. Records only ever wait in
6
+ memory: there is no disk persistence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import threading
13
+ import time
14
+ import weakref
15
+ from collections import deque
16
+ from collections.abc import Callable, Sequence
17
+ from dataclasses import dataclass
18
+ from typing import Generic, TypeVar
19
+
20
+ from ._options import CaptureCode, QueueOptions
21
+ from ._suppression import reporting
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class QueueStats:
28
+ accepted: int
29
+ exported: int
30
+ dropped: int
31
+ pending: int
32
+
33
+
34
+ class _Batch(Generic[T]):
35
+ def __init__(self, records: list[tuple[int, T]]) -> None:
36
+ self.records = records
37
+ self.dropped = False
38
+
39
+
40
+ class ExportQueue(Generic[T]):
41
+ def __init__(
42
+ self,
43
+ name: str,
44
+ options: QueueOptions,
45
+ export: Callable[[Sequence[T]], bool],
46
+ report: Callable[[CaptureCode, int], None],
47
+ shutdown_exporter: Callable[[], None],
48
+ ) -> None:
49
+ self._options = options
50
+ self._export = export
51
+ self._report = report
52
+ self._shutdown_exporter = shutdown_exporter
53
+ self._lock = threading.Condition()
54
+ self._records: deque[tuple[int, T]] = deque()
55
+ self._active: _Batch[T] | None = None
56
+ self._accepted = 0
57
+ self._exported = 0
58
+ self._dropped = 0
59
+ self._pending = 0
60
+ self._force = 0
61
+ self._closed = False
62
+ self._stop_once = threading.Lock()
63
+ self._stopped: threading.Event | None = None
64
+ self._name = name
65
+ self._worker = threading.Thread(target=self._run, name=f"agentrail-{name}", daemon=True)
66
+ self._worker.start()
67
+ # A process that forks after start-up (gunicorn --preload, multiprocessing)
68
+ # gives the child this queue without its worker thread, and possibly with
69
+ # a lock held by a thread that no longer exists. The child starts afresh.
70
+ if hasattr(os, "register_at_fork"):
71
+ queue = weakref.ref(self)
72
+
73
+ def reinitialize() -> None:
74
+ alive = queue()
75
+ if alive is not None:
76
+ alive._after_fork() # pyright: ignore[reportPrivateUsage]
77
+
78
+ os.register_at_fork(after_in_child=reinitialize)
79
+
80
+ def _after_fork(self) -> None:
81
+ """In a forked child: the parent's records stay the parent's to send."""
82
+ self._lock = threading.Condition()
83
+ self._records = deque()
84
+ self._active = None
85
+ self._accepted = 0
86
+ self._exported = 0
87
+ self._dropped = 0
88
+ self._pending = 0
89
+ self._force = 0
90
+ self._stop_once = threading.Lock()
91
+ if self._closed:
92
+ return
93
+ self._stopped = None
94
+ self._worker = threading.Thread(target=self._run, name=f"agentrail-{self._name}", daemon=True)
95
+ self._worker.start()
96
+
97
+ def diagnostics(self) -> QueueStats:
98
+ with self._lock:
99
+ return QueueStats(self._accepted, self._exported, self._dropped, self._pending)
100
+
101
+ def add(self, value: T) -> bool:
102
+ """Never waits for an export. False means the record was dropped."""
103
+ with self._lock:
104
+ retained = len(self._records) + (0 if self._active is None else len(self._active.records))
105
+ code: CaptureCode | None = None
106
+ if self._closed:
107
+ code = "closed"
108
+ elif retained >= self._options.max_records:
109
+ code = "queue_full"
110
+ if code is None:
111
+ self._accepted += 1
112
+ self._pending += 1
113
+ self._records.append((self._accepted, value))
114
+ if len(self._records) >= self._options.batch_size:
115
+ self._lock.notify_all()
116
+ return True
117
+ self._dropped += 1
118
+ self._report(code, 1)
119
+ return False
120
+
121
+ def flush(self, timeout: float | None = None) -> None:
122
+ """Attempts every record accepted when called. Delivery is not guaranteed."""
123
+ limit = self._options.flush_timeout if timeout is None else min(timeout, self._options.flush_timeout)
124
+ with self._lock:
125
+ target = self._accepted
126
+ self._force = max(self._force, target)
127
+ self._lock.notify_all()
128
+ self._wait(target, time.monotonic() + limit)
129
+
130
+ def shutdown(self, timeout: float | None = None) -> None:
131
+ """Closes admission, drains once within the shutdown deadline, then stops."""
132
+ with self._stop_once:
133
+ first = self._stopped is None
134
+ if first:
135
+ self._stopped = threading.Event()
136
+ stopped = self._stopped
137
+ assert stopped is not None
138
+ if not first:
139
+ stopped.wait(self._options.shutdown_timeout)
140
+ return
141
+ limit = self._options.shutdown_timeout if timeout is None else min(timeout, self._options.shutdown_timeout)
142
+ deadline = time.monotonic() + limit
143
+ with self._lock:
144
+ self._closed = True
145
+ target = self._accepted
146
+ self._force = target
147
+ self._lock.notify_all()
148
+ self._wait(target, deadline)
149
+ self._worker.join(max(0.0, deadline - time.monotonic()))
150
+ with reporting():
151
+ self._shutdown_exporter()
152
+ stopped.set()
153
+
154
+ def _outstanding(self, target: int) -> bool:
155
+ active = self._active
156
+ if active is not None and not active.dropped and active.records[0][0] <= target:
157
+ return True
158
+ return len(self._records) > 0 and self._records[0][0] <= target
159
+
160
+ def _wait(self, target: int, deadline: float) -> None:
161
+ count = 0
162
+ with self._lock:
163
+ while self._outstanding(target):
164
+ remaining = deadline - time.monotonic()
165
+ if remaining <= 0:
166
+ count = self._abort(target)
167
+ break
168
+ self._lock.wait(remaining)
169
+ if count > 0:
170
+ self._report("deadline", count)
171
+
172
+ def _abort(self, target: int) -> int:
173
+ count = 0
174
+ active = self._active
175
+ if active is not None and not active.dropped and active.records[0][0] <= target:
176
+ active.dropped = True
177
+ count += len(active.records)
178
+ while self._records and self._records[0][0] <= target:
179
+ self._records.popleft()
180
+ count += 1
181
+ self._dropped += count
182
+ self._pending -= count
183
+ self._lock.notify_all()
184
+ return count
185
+
186
+ def _next_batch(self) -> _Batch[T] | None:
187
+ """Called with the lock held. None when the worker should wait or stop."""
188
+ size = self._options.batch_size
189
+ records = self._records
190
+ if not records:
191
+ return None
192
+ if not self._closed and len(records) < size and records[0][0] > self._force:
193
+ return None
194
+ count = min(len(records), size)
195
+ # A partial batch never sweeps in records accepted after the flush that forced it.
196
+ if not self._closed and records[0][0] <= self._force:
197
+ while count > 1 and records[count - 1][0] > self._force:
198
+ count -= 1
199
+ batch = _Batch([records.popleft() for _ in range(count)])
200
+ self._active = batch
201
+ return batch
202
+
203
+ def _run(self) -> None:
204
+ with reporting():
205
+ next_tick = time.monotonic() + self._options.scheduled_delay
206
+ while True:
207
+ with self._lock:
208
+ batch = self._next_batch()
209
+ while batch is None:
210
+ if self._closed and not self._records:
211
+ return
212
+ remaining = next_tick - time.monotonic()
213
+ if remaining <= 0:
214
+ self._force = self._accepted
215
+ next_tick = time.monotonic() + self._options.scheduled_delay
216
+ else:
217
+ self._lock.wait(remaining)
218
+ batch = self._next_batch()
219
+ values = [value for _, value in batch.records]
220
+ try:
221
+ succeeded = self._export(values)
222
+ except Exception: # noqa: BLE001 - a failed export is reported as loss, never raised
223
+ succeeded = False
224
+ with self._lock:
225
+ count = len(batch.records)
226
+ reported = not batch.dropped
227
+ if reported:
228
+ self._pending -= count
229
+ if succeeded:
230
+ self._exported += count
231
+ else:
232
+ self._dropped += count
233
+ self._active = None
234
+ self._lock.notify_all()
235
+ if reported and not succeeded:
236
+ self._report("export_failed", count)
@@ -0,0 +1,110 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """Per-installation state: context keys, the resource, and span metadata."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import threading
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+
10
+ from opentelemetry import context as otel_context
11
+ from opentelemetry.sdk.resources import Resource
12
+ from opentelemetry.sdk.util.instrumentation import InstrumentationScope
13
+ from opentelemetry.trace import Link
14
+ from opentelemetry.util.types import AttributeValue
15
+
16
+ from ._handoff import key_id, keyed_hash
17
+ from ._options import CaptureCode, CaptureError, MilestoneContext, Resolved, Signal, VerifiedIdentity
18
+ from ._version import VERSION
19
+
20
+ COMPONENT = "agentrail-python"
21
+ Attributes = dict[str, AttributeValue]
22
+
23
+
24
+ @dataclass
25
+ class RequestState:
26
+ """One managed operation's evidence, shared by its child spans and tasks."""
27
+
28
+ verified: bool = False
29
+ links: list[Link] = field(default_factory=lambda: [])
30
+ location: Attributes = field(default_factory=lambda: {})
31
+ _identity: Attributes | None = None
32
+ _lock: threading.Lock = field(default_factory=threading.Lock)
33
+
34
+ def principal(self) -> Attributes:
35
+ with self._lock:
36
+ return dict(self._identity or {})
37
+
38
+ def assert_identity(self, identity: Attributes) -> bool:
39
+ """Keeps the first assertion. True when ``identity`` conflicts with it."""
40
+ with self._lock:
41
+ if self._identity is None:
42
+ self._identity = identity
43
+ return False
44
+ return self._identity != identity
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Mark:
49
+ name: str
50
+ details: MilestoneContext
51
+ performance: Attributes | None = None
52
+ identity: Attributes | None = None
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class SpanMeta:
57
+ state: RequestState
58
+ mark: Mark | None
59
+
60
+
61
+ class Runtime:
62
+ def __init__(self, options: Resolved) -> None:
63
+ self.options = options
64
+ unique = uuid.uuid4().hex
65
+ self.state_key = otel_context.create_key(f"agentrail.state.{unique}")
66
+ self.mark_key = otel_context.create_key(f"agentrail.milestone.{unique}")
67
+ self.key_id = key_id(options.input.hash_key)
68
+ self.resource = Resource(
69
+ {
70
+ "service.name": options.input.service_name,
71
+ "service.instance.id": options.instance,
72
+ "agentrail.component.name": COMPONENT,
73
+ "agentrail.component.version": VERSION,
74
+ "agentrail.hash_key_id": self.key_id,
75
+ "agentrail.capture.surfaces": "http,mcp,auth,custom",
76
+ }
77
+ )
78
+ self.scope = InstrumentationScope(COMPONENT, VERSION)
79
+ self._spans: dict[tuple[int, int], SpanMeta] = {}
80
+ self._spans_lock = threading.Lock()
81
+
82
+ def state(self, ctx: otel_context.Context | None = None) -> RequestState | None:
83
+ value = otel_context.get_value(self.state_key, ctx)
84
+ return value if isinstance(value, RequestState) else None
85
+
86
+ def mark(self, ctx: otel_context.Context | None = None) -> Mark | None:
87
+ value = otel_context.get_value(self.mark_key, ctx)
88
+ return value if isinstance(value, Mark) else None
89
+
90
+ def remember(self, trace_id: int, span_id: int, meta: SpanMeta) -> None:
91
+ with self._spans_lock:
92
+ self._spans[(trace_id, span_id)] = meta
93
+
94
+ def forget(self, trace_id: int, span_id: int) -> SpanMeta | None:
95
+ with self._spans_lock:
96
+ return self._spans.pop((trace_id, span_id), None)
97
+
98
+ def identity(self, identity: VerifiedIdentity) -> Attributes:
99
+ key = self.options.input.hash_key
100
+ attributes: Attributes = {
101
+ "agentrail.identity.verified": True,
102
+ "agentrail.principal.kind": identity.kind,
103
+ "agentrail.principal.id_hash": keyed_hash(key, identity.id),
104
+ }
105
+ if identity.acting_user_id is not None:
106
+ attributes["agentrail.principal.acting_user_hash"] = keyed_hash(key, identity.acting_user_id)
107
+ return attributes
108
+
109
+ def report(self, code: CaptureCode, signal: Signal, count: int = 1) -> None:
110
+ self.options.report(CaptureError(code, signal, count))
@@ -0,0 +1,33 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """Work the SDK does for itself is never captured.
3
+
4
+ Error reporting and export run under this flag so a callback that logs, or an
5
+ exporter that logs its own failures, cannot feed the queue it reports on.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Generator
11
+ from contextlib import contextmanager
12
+ from contextvars import ContextVar
13
+
14
+ from opentelemetry import context as otel_context
15
+
16
+ _flag: ContextVar[bool] = ContextVar("agentrail_python_suppressed", default=False)
17
+ _OTEL_KEY = otel_context._SUPPRESS_INSTRUMENTATION_KEY # pyright: ignore[reportPrivateUsage]
18
+
19
+
20
+ def suppressed() -> bool:
21
+ return _flag.get()
22
+
23
+
24
+ @contextmanager
25
+ def reporting() -> Generator[None]:
26
+ """Run SDK-internal work with capture and OTel instrumentation suppressed."""
27
+ flag = _flag.set(True)
28
+ token = otel_context.attach(otel_context.set_value(_OTEL_KEY, True))
29
+ try:
30
+ yield
31
+ finally:
32
+ otel_context.detach(token)
33
+ _flag.reset(flag)
@@ -0,0 +1,49 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """Text limits counted the way the collector and the JavaScript SDKs count them.
3
+
4
+ Limits are UTF-16 code units, because the collector and agentrail-node measure
5
+ JavaScript string length. JSON is encoded exactly as ``JSON.stringify`` does for
6
+ well-formed strings, so hashes and signatures agree across the SDKs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+
14
+ def utf16_length(value: str) -> int:
15
+ return sum(2 if ord(character) > 0xFFFF else 1 for character in value)
16
+
17
+
18
+ def well_formed(value: str) -> bool:
19
+ """True when the string has no lone surrogate, so it encodes as UTF-8."""
20
+ try:
21
+ value.encode("utf-8")
22
+ except UnicodeEncodeError:
23
+ return False
24
+ return True
25
+
26
+
27
+ def valid_text(value: str, limit: int) -> bool:
28
+ """Well-formed, without NUL, and at most ``limit`` UTF-16 units."""
29
+ return well_formed(value) and "\x00" not in value and utf16_length(value) <= limit
30
+
31
+
32
+ def bounded(value: str, limit: int) -> str:
33
+ """Drop NUL, replace lone surrogates and cut to ``limit`` UTF-16 units.
34
+
35
+ A cut never splits a code point.
36
+ """
37
+ cleaned = value.encode("utf-8", "replace").decode("utf-8").replace("\x00", "")
38
+ units = 0
39
+ for index, character in enumerate(cleaned):
40
+ width = 2 if ord(character) > 0xFFFF else 1
41
+ if units + width > limit:
42
+ return cleaned[:index]
43
+ units += width
44
+ return cleaned
45
+
46
+
47
+ def js_json(value: object) -> str:
48
+ """``JSON.stringify`` for strings, finite numbers, booleans, null and lists of them."""
49
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ VERSION = "0.1.0"
@@ -0,0 +1,111 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """ASGI middleware: FastAPI, Starlette, Django (ASGI), Quart, Litestar."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Awaitable, Callable, MutableMapping
7
+ from typing import TYPE_CHECKING, Any
8
+ from urllib.parse import quote
9
+
10
+ from opentelemetry import context as otel_context
11
+ from opentelemetry.trace import StatusCode
12
+
13
+ from ._options import IncomingRequest
14
+
15
+ if TYPE_CHECKING:
16
+ from ._client import Client
17
+
18
+ Scope = MutableMapping[str, Any]
19
+ Message = MutableMapping[str, Any]
20
+ Receive = Callable[[], Awaitable[Message]]
21
+ Send = Callable[[Message], Awaitable[None]]
22
+ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
23
+
24
+
25
+ def _request(scope: Scope) -> IncomingRequest:
26
+ headers: dict[str, list[str]] = {}
27
+ for raw_name, raw_value in scope.get("headers", ()):
28
+ name = bytes(raw_name).decode("latin-1").lower()
29
+ headers.setdefault(name, []).append(bytes(raw_value).decode("latin-1"))
30
+ raw_path = scope.get("raw_path")
31
+ path = (
32
+ bytes(raw_path).decode("latin-1")
33
+ if raw_path
34
+ else quote(str(scope.get("root_path", "")) + str(scope.get("path", "/")), safe="/%:@!$&'()*+,;=~")
35
+ )
36
+ query = bytes(scope.get("query_string", b"")).decode("latin-1")
37
+ target = f"{path}?{query}" if query else path
38
+ host = headers.get("host", [""])[0]
39
+ if not host:
40
+ server = scope.get("server")
41
+ host = f"{server[0]}:{server[1]}" if server else "localhost"
42
+ client = scope.get("client")
43
+ return IncomingRequest(
44
+ method=str(scope.get("method", "GET")),
45
+ target=target,
46
+ scheme=str(scope.get("scheme", "http")),
47
+ host=host,
48
+ # A header sent more than once is joined, so a handoff or traceparent
49
+ # sent twice cannot verify.
50
+ headers={name: ", ".join(values) for name, values in headers.items()},
51
+ client=None if not client else str(client[0]),
52
+ raw=scope,
53
+ )
54
+
55
+
56
+ def _route(scope: Scope) -> str | None:
57
+ route = scope.get("route")
58
+ for attribute in ("path_format", "path"):
59
+ value = getattr(route, attribute, None)
60
+ if isinstance(value, str) and value:
61
+ return value
62
+ return None
63
+
64
+
65
+ class AgentrailASGIMiddleware:
66
+ """Records each HTTP request the wrapped application serves as a journey step.
67
+
68
+ ``app.add_middleware(AgentrailASGIMiddleware, client=agentrail)`` in FastAPI and
69
+ Starlette, or ``application = agentrail.asgi(application)``. Install it once.
70
+ """
71
+
72
+ def __init__(self, app: ASGIApp, *, client: Client) -> None:
73
+ self.app = app
74
+ self._client = client
75
+
76
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
77
+ if scope.get("type") != "http":
78
+ await self.app(scope, receive, send)
79
+ return
80
+ entered = self._client._enter(_request(scope)) # pyright: ignore[reportPrivateUsage]
81
+ if entered is None:
82
+ await self.app(scope, receive, send)
83
+ return
84
+ span, ctx = entered
85
+ status: list[int] = []
86
+
87
+ async def observed_send(message: Message) -> None:
88
+ if message.get("type") == "http.response.start" and not status:
89
+ status.append(int(message["status"]))
90
+ await send(message)
91
+
92
+ token = otel_context.attach(ctx)
93
+ try:
94
+ await self.app(scope, receive, observed_send)
95
+ except BaseException as error:
96
+ span.set_status(StatusCode.ERROR)
97
+ span.set_attribute("error.type", type(error).__qualname__)
98
+ raise
99
+ finally:
100
+ try:
101
+ code = status[0] if status else None
102
+ if code is not None:
103
+ span.set_attribute("http.response.status_code", code)
104
+ if code >= 500:
105
+ span.set_status(StatusCode.ERROR)
106
+ route = _route(scope)
107
+ if route is not None:
108
+ span.set_attribute("http.route", route)
109
+ span.end()
110
+ finally:
111
+ otel_context.detach(token)