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.
- agentrail_python/__init__.py +64 -0
- agentrail_python/_client.py +362 -0
- agentrail_python/_handoff.py +214 -0
- agentrail_python/_init.py +64 -0
- agentrail_python/_options.py +305 -0
- agentrail_python/_outbound.py +76 -0
- agentrail_python/_performance.py +63 -0
- agentrail_python/_privacy.py +255 -0
- agentrail_python/_processors.py +259 -0
- agentrail_python/_queue.py +236 -0
- agentrail_python/_runtime.py +110 -0
- agentrail_python/_suppression.py +33 -0
- agentrail_python/_text.py +49 -0
- agentrail_python/_version.py +2 -0
- agentrail_python/asgi.py +111 -0
- agentrail_python/httpx.py +159 -0
- agentrail_python/logging.py +70 -0
- agentrail_python/mcp.py +125 -0
- agentrail_python/py.typed +0 -0
- agentrail_python/requests.py +67 -0
- agentrail_python/wsgi.py +158 -0
- agentrail_python-0.1.0.dist-info/METADATA +625 -0
- agentrail_python-0.1.0.dist-info/RECORD +25 -0
- agentrail_python-0.1.0.dist-info/WHEEL +4 -0
- agentrail_python-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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)
|
agentrail_python/asgi.py
ADDED
|
@@ -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)
|