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,64 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Agentrail for Python: backend journey evidence over OpenTelemetry.
|
|
3
|
+
|
|
4
|
+
Standalone: ``agentrail = init(Options(...))``. With an existing OpenTelemetry
|
|
5
|
+
setup: ``create_processors(Options(...))``, then ``bind`` your provider.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._client import Client, Diagnostics, MilestoneScope
|
|
9
|
+
from ._init import init
|
|
10
|
+
from ._options import (
|
|
11
|
+
AuthorizedOperation,
|
|
12
|
+
CaptureCode,
|
|
13
|
+
CaptureError,
|
|
14
|
+
CollectorOptions,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
IncomingRequest,
|
|
17
|
+
IPLocation,
|
|
18
|
+
LocationSource,
|
|
19
|
+
LogsOptions,
|
|
20
|
+
McpContext,
|
|
21
|
+
MilestoneContext,
|
|
22
|
+
Options,
|
|
23
|
+
PerformanceContext,
|
|
24
|
+
PluginInvocation,
|
|
25
|
+
PrincipalKind,
|
|
26
|
+
QueueOptions,
|
|
27
|
+
Surface,
|
|
28
|
+
VerifiedIdentity,
|
|
29
|
+
)
|
|
30
|
+
from ._privacy import StructuredLog
|
|
31
|
+
from ._processors import Processors, create_processors
|
|
32
|
+
from ._queue import QueueStats
|
|
33
|
+
from ._version import VERSION
|
|
34
|
+
|
|
35
|
+
__version__ = VERSION
|
|
36
|
+
__all__ = [
|
|
37
|
+
"VERSION",
|
|
38
|
+
"AuthorizedOperation",
|
|
39
|
+
"CaptureCode",
|
|
40
|
+
"CaptureError",
|
|
41
|
+
"Client",
|
|
42
|
+
"CollectorOptions",
|
|
43
|
+
"ConfigurationError",
|
|
44
|
+
"Diagnostics",
|
|
45
|
+
"IPLocation",
|
|
46
|
+
"IncomingRequest",
|
|
47
|
+
"LocationSource",
|
|
48
|
+
"LogsOptions",
|
|
49
|
+
"McpContext",
|
|
50
|
+
"MilestoneContext",
|
|
51
|
+
"MilestoneScope",
|
|
52
|
+
"Options",
|
|
53
|
+
"PerformanceContext",
|
|
54
|
+
"PluginInvocation",
|
|
55
|
+
"PrincipalKind",
|
|
56
|
+
"Processors",
|
|
57
|
+
"QueueOptions",
|
|
58
|
+
"QueueStats",
|
|
59
|
+
"StructuredLog",
|
|
60
|
+
"Surface",
|
|
61
|
+
"VerifiedIdentity",
|
|
62
|
+
"create_processors",
|
|
63
|
+
"init",
|
|
64
|
+
]
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""The client an application records with."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
15
|
+
|
|
16
|
+
from opentelemetry import context as otel_context
|
|
17
|
+
from opentelemetry import trace
|
|
18
|
+
from opentelemetry.trace import Link, NonRecordingSpan, Span, SpanContext, SpanKind, StatusCode, TraceFlags
|
|
19
|
+
|
|
20
|
+
from ._handoff import HANDOFF_HEADER, Refused, parse_traceparent, verify
|
|
21
|
+
from ._options import (
|
|
22
|
+
LOCATION_SOURCES,
|
|
23
|
+
PRINCIPAL_KINDS,
|
|
24
|
+
SURFACES,
|
|
25
|
+
ConfigurationError,
|
|
26
|
+
IncomingRequest,
|
|
27
|
+
MilestoneContext,
|
|
28
|
+
VerifiedIdentity,
|
|
29
|
+
)
|
|
30
|
+
from ._performance import performance_attributes
|
|
31
|
+
from ._privacy import StructuredLog, clean_context, copy_log
|
|
32
|
+
from ._queue import ExportQueue, QueueStats
|
|
33
|
+
from ._runtime import Attributes, Mark, RequestState, Runtime
|
|
34
|
+
from ._suppression import suppressed
|
|
35
|
+
from ._text import bounded, valid_text
|
|
36
|
+
from ._version import VERSION
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from opentelemetry.sdk._logs import ReadableLogRecord
|
|
40
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
41
|
+
|
|
42
|
+
from .asgi import AgentrailASGIMiddleware, ASGIApp
|
|
43
|
+
from .wsgi import AgentrailWSGIMiddleware, WSGIApp
|
|
44
|
+
|
|
45
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
46
|
+
_COUNTRY = re.compile(r"[A-Z]{2}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Diagnostics:
|
|
51
|
+
spans: QueueStats
|
|
52
|
+
logs: QueueStats
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _error_type(error: BaseException) -> str:
|
|
56
|
+
return bounded(type(error).__qualname__, 4096)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MilestoneScope:
|
|
60
|
+
"""A milestone around a block: ``with`` or ``async with``.
|
|
61
|
+
|
|
62
|
+
The block's exception is recorded and re-raised unchanged.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, client: Client, name: str, details: MilestoneContext) -> None:
|
|
66
|
+
client._validate_milestone(name, details) # pyright: ignore[reportPrivateUsage]
|
|
67
|
+
self._client = client
|
|
68
|
+
self._name = name
|
|
69
|
+
self._details = details
|
|
70
|
+
self._span: Span | None = None
|
|
71
|
+
self._token: object | None = None
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> MilestoneScope:
|
|
74
|
+
span, ctx = self._client._start(self._name, self._details) # pyright: ignore[reportPrivateUsage]
|
|
75
|
+
self._span = span
|
|
76
|
+
self._token = otel_context.attach(ctx)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(
|
|
80
|
+
self,
|
|
81
|
+
exc_type: type[BaseException] | None,
|
|
82
|
+
exc: BaseException | None,
|
|
83
|
+
traceback: TracebackType | None,
|
|
84
|
+
) -> None:
|
|
85
|
+
span, token = self._span, self._token
|
|
86
|
+
self._span = self._token = None
|
|
87
|
+
if span is None or token is None:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
if exc is not None:
|
|
91
|
+
span.set_status(StatusCode.ERROR)
|
|
92
|
+
span.set_attribute("error.type", _error_type(exc))
|
|
93
|
+
span.end()
|
|
94
|
+
finally:
|
|
95
|
+
otel_context.detach(cast(Any, token))
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> MilestoneScope:
|
|
98
|
+
return self.__enter__()
|
|
99
|
+
|
|
100
|
+
async def __aexit__(
|
|
101
|
+
self,
|
|
102
|
+
exc_type: type[BaseException] | None,
|
|
103
|
+
exc: BaseException | None,
|
|
104
|
+
traceback: TracebackType | None,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.__exit__(exc_type, exc, traceback)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Client:
|
|
110
|
+
"""Records backend evidence with a bound tracer provider.
|
|
111
|
+
|
|
112
|
+
Get one from ``init`` or from ``create_processors(...).bind(provider)``.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
runtime: Runtime,
|
|
118
|
+
provider: trace.TracerProvider,
|
|
119
|
+
spans: ExportQueue[ReadableSpan],
|
|
120
|
+
logs: ExportQueue[ReadableLogRecord],
|
|
121
|
+
) -> None:
|
|
122
|
+
self._runtime = runtime
|
|
123
|
+
self._tracer = provider.get_tracer(runtime.scope.name, VERSION)
|
|
124
|
+
self._spans = spans
|
|
125
|
+
self._logs = logs
|
|
126
|
+
self._closed = False
|
|
127
|
+
self._shutdown_lock = threading.Lock()
|
|
128
|
+
self._on_shutdown: list[Callable[[], object]] = []
|
|
129
|
+
|
|
130
|
+
# Identity and milestones
|
|
131
|
+
|
|
132
|
+
def identify(self, identity: VerifiedIdentity) -> None:
|
|
133
|
+
"""Assert an identity the application has already authenticated.
|
|
134
|
+
|
|
135
|
+
Only inside a managed request or milestone. IDs are hashed before export.
|
|
136
|
+
"""
|
|
137
|
+
if (
|
|
138
|
+
identity.kind not in PRINCIPAL_KINDS
|
|
139
|
+
or not identity.id
|
|
140
|
+
or not valid_text(identity.id, 1 << 20)
|
|
141
|
+
or (identity.acting_user_id is not None and not identity.acting_user_id)
|
|
142
|
+
or (identity.acting_user_id is not None and not valid_text(identity.acting_user_id, 1 << 20))
|
|
143
|
+
):
|
|
144
|
+
raise ConfigurationError("Use a supported principal kind and nonempty, well-formed identity IDs.")
|
|
145
|
+
if suppressed():
|
|
146
|
+
return
|
|
147
|
+
state = self._runtime.state()
|
|
148
|
+
if state is None:
|
|
149
|
+
self._runtime.report("identity_outside_request", "context")
|
|
150
|
+
return
|
|
151
|
+
hashed = self._runtime.identity(identity)
|
|
152
|
+
if state.assert_identity(hashed):
|
|
153
|
+
# The first assertion stands; a different one is recorded as its own auth step.
|
|
154
|
+
span, _ = self._start("identity.asserted", MilestoneContext(surface="auth", kind="auth.identity"), hashed)
|
|
155
|
+
span.end()
|
|
156
|
+
|
|
157
|
+
def milestone(self, name: str, details: MilestoneContext) -> None:
|
|
158
|
+
"""Record an instant step."""
|
|
159
|
+
self._validate_milestone(name, details)
|
|
160
|
+
if suppressed():
|
|
161
|
+
return
|
|
162
|
+
span, _ = self._start(name, details)
|
|
163
|
+
span.end()
|
|
164
|
+
|
|
165
|
+
def start_milestone(self, name: str, details: MilestoneContext) -> MilestoneScope:
|
|
166
|
+
"""A step around a block: ``with client.start_milestone(...):``."""
|
|
167
|
+
return MilestoneScope(self, name, details)
|
|
168
|
+
|
|
169
|
+
def with_milestone(self, name: str, details: MilestoneContext) -> Callable[[F], F]:
|
|
170
|
+
"""Decorate a function or coroutine function: each call is one step."""
|
|
171
|
+
self._validate_milestone(name, details)
|
|
172
|
+
|
|
173
|
+
def decorate(function: F) -> F:
|
|
174
|
+
if inspect.iscoroutinefunction(function):
|
|
175
|
+
|
|
176
|
+
@functools.wraps(function)
|
|
177
|
+
async def run_async(*args: Any, **kwargs: Any) -> Any:
|
|
178
|
+
async with MilestoneScope(self, name, details):
|
|
179
|
+
return await function(*args, **kwargs)
|
|
180
|
+
|
|
181
|
+
return cast(F, run_async)
|
|
182
|
+
|
|
183
|
+
@functools.wraps(function)
|
|
184
|
+
def run(*args: Any, **kwargs: Any) -> Any:
|
|
185
|
+
with MilestoneScope(self, name, details):
|
|
186
|
+
return function(*args, **kwargs)
|
|
187
|
+
|
|
188
|
+
return cast(F, run)
|
|
189
|
+
|
|
190
|
+
return decorate
|
|
191
|
+
|
|
192
|
+
def _validate_milestone(self, name: str, details: MilestoneContext) -> None:
|
|
193
|
+
if (
|
|
194
|
+
not name
|
|
195
|
+
or not details.kind
|
|
196
|
+
or not valid_text(name, 256)
|
|
197
|
+
or not valid_text(details.kind, 256)
|
|
198
|
+
or details.surface not in SURFACES
|
|
199
|
+
):
|
|
200
|
+
raise ConfigurationError("Milestone name and kind must contain 1-256 characters, with a known surface.")
|
|
201
|
+
|
|
202
|
+
def _start(
|
|
203
|
+
self, name: str, details: MilestoneContext, identity: Attributes | None = None
|
|
204
|
+
) -> tuple[Span, otel_context.Context]:
|
|
205
|
+
performance = None
|
|
206
|
+
if details.performance is not None:
|
|
207
|
+
try:
|
|
208
|
+
performance = performance_attributes(details.performance, self._runtime.options.input.hash_key)
|
|
209
|
+
except ConfigurationError:
|
|
210
|
+
self._runtime.report("invalid_performance", "context")
|
|
211
|
+
base = otel_context.get_current()
|
|
212
|
+
state = self._runtime.state(base)
|
|
213
|
+
if state is None:
|
|
214
|
+
external = trace.get_current_span(base).get_span_context()
|
|
215
|
+
links = [Link(clean_context(external))] if external.is_valid else []
|
|
216
|
+
state = RequestState(links=links)
|
|
217
|
+
# Its own managed scope: an unrelated caller's trace is a link, never the parent.
|
|
218
|
+
base = otel_context.set_value(self._runtime.state_key, state, otel_context.Context())
|
|
219
|
+
mark = Mark(name=name, details=details, performance=performance, identity=identity)
|
|
220
|
+
marked = otel_context.set_value(self._runtime.mark_key, mark, base)
|
|
221
|
+
parented = trace.get_current_span(base).get_span_context().is_valid
|
|
222
|
+
span = self._tracer.start_span(name, context=marked, links=[] if parented else state.links)
|
|
223
|
+
if not span.is_recording():
|
|
224
|
+
self._runtime.report("entry_not_recording", "traces")
|
|
225
|
+
return span, trace.set_span_in_context(span, base)
|
|
226
|
+
|
|
227
|
+
# Logs
|
|
228
|
+
|
|
229
|
+
def log(self, entry: StructuredLog) -> None:
|
|
230
|
+
"""Record a structured log, correlated with the current managed span."""
|
|
231
|
+
if suppressed():
|
|
232
|
+
return
|
|
233
|
+
state = self._runtime.state()
|
|
234
|
+
if state is None and self._runtime.options.input.logs.exclude_uncorrelated:
|
|
235
|
+
return
|
|
236
|
+
span = None
|
|
237
|
+
if state is not None:
|
|
238
|
+
context = trace.get_current_span().get_span_context()
|
|
239
|
+
span = context if context.is_valid else None
|
|
240
|
+
now = time.time_ns()
|
|
241
|
+
self._logs.add(copy_log(entry, now, now, span, self._runtime))
|
|
242
|
+
|
|
243
|
+
# Delivery
|
|
244
|
+
|
|
245
|
+
def flush(self, timeout: float | None = None) -> None:
|
|
246
|
+
"""Wait for delivery attempts of what was recorded so far. Not a delivery guarantee."""
|
|
247
|
+
limit = self._runtime.options.queue.flush_timeout if timeout is None else timeout
|
|
248
|
+
deadline = time.monotonic() + limit
|
|
249
|
+
self._spans.flush(limit)
|
|
250
|
+
self._logs.flush(max(0.0, deadline - time.monotonic()))
|
|
251
|
+
|
|
252
|
+
def shutdown(self) -> None:
|
|
253
|
+
"""Drain once and stop. Terminal and idempotent."""
|
|
254
|
+
with self._shutdown_lock:
|
|
255
|
+
if self._closed:
|
|
256
|
+
return
|
|
257
|
+
self._closed = True
|
|
258
|
+
deadline = time.monotonic() + self._runtime.options.queue.shutdown_timeout
|
|
259
|
+
self._spans.shutdown()
|
|
260
|
+
self._logs.shutdown(max(0.0, deadline - time.monotonic()))
|
|
261
|
+
for stop in self._on_shutdown:
|
|
262
|
+
stop()
|
|
263
|
+
|
|
264
|
+
def diagnostics(self) -> Diagnostics:
|
|
265
|
+
return Diagnostics(self._spans.diagnostics(), self._logs.diagnostics())
|
|
266
|
+
|
|
267
|
+
# HTTP
|
|
268
|
+
|
|
269
|
+
def asgi(self, app: ASGIApp) -> AgentrailASGIMiddleware:
|
|
270
|
+
"""Wrap an ASGI application (FastAPI, Starlette, Django ASGI, ...)."""
|
|
271
|
+
from .asgi import AgentrailASGIMiddleware
|
|
272
|
+
|
|
273
|
+
return AgentrailASGIMiddleware(app, client=self)
|
|
274
|
+
|
|
275
|
+
def wsgi(self, app: WSGIApp) -> AgentrailWSGIMiddleware:
|
|
276
|
+
"""Wrap a WSGI application (Flask, Django WSGI, ...)."""
|
|
277
|
+
from .wsgi import AgentrailWSGIMiddleware
|
|
278
|
+
|
|
279
|
+
return AgentrailWSGIMiddleware(app, client=self)
|
|
280
|
+
|
|
281
|
+
def _managed(self) -> bool:
|
|
282
|
+
return not self._closed and not suppressed() and self._runtime.state() is not None
|
|
283
|
+
|
|
284
|
+
def _enter(self, request: IncomingRequest) -> tuple[Span, otel_context.Context] | None:
|
|
285
|
+
"""Admit an incoming request: verify its handoff, then open its entry span."""
|
|
286
|
+
if self._closed or suppressed():
|
|
287
|
+
return None
|
|
288
|
+
if self._ignored(request):
|
|
289
|
+
return None
|
|
290
|
+
parent = request.headers.get("traceparent")
|
|
291
|
+
token = request.headers.get(HANDOFF_HEADER)
|
|
292
|
+
now = time.time_ns() // 1_000_000
|
|
293
|
+
state = RequestState()
|
|
294
|
+
ctx = otel_context.Context()
|
|
295
|
+
try:
|
|
296
|
+
remote = verify(self._runtime.options.input.hash_key, token, parent, request.method, request.target, now)
|
|
297
|
+
except Refused as refused:
|
|
298
|
+
external = parse_traceparent(parent)
|
|
299
|
+
if external is not None:
|
|
300
|
+
state.links.append(
|
|
301
|
+
Link(SpanContext(external.trace_id, external.span_id, True, TraceFlags(external.flags & 1)))
|
|
302
|
+
)
|
|
303
|
+
if refused.reason != "missing":
|
|
304
|
+
self._runtime.report("handoff_refused", "context")
|
|
305
|
+
else:
|
|
306
|
+
state.verified = True
|
|
307
|
+
remote_context = SpanContext(remote.trace_id, remote.span_id, True, TraceFlags(remote.flags & 1))
|
|
308
|
+
ctx = trace.set_span_in_context(NonRecordingSpan(remote_context), ctx)
|
|
309
|
+
state.location = self._location(request)
|
|
310
|
+
ctx = otel_context.set_value(self._runtime.state_key, state, ctx)
|
|
311
|
+
attributes: Attributes = {
|
|
312
|
+
"http.request.method": request.method,
|
|
313
|
+
"url.full": f"{request.scheme}://{request.host}{request.target}",
|
|
314
|
+
}
|
|
315
|
+
user_agent = request.headers.get("user-agent")
|
|
316
|
+
if user_agent:
|
|
317
|
+
attributes["user_agent.original"] = user_agent
|
|
318
|
+
span = self._tracer.start_span(
|
|
319
|
+
f"HTTP {request.method}", context=ctx, kind=SpanKind.SERVER, attributes=attributes
|
|
320
|
+
)
|
|
321
|
+
if not span.is_recording():
|
|
322
|
+
self._runtime.report("entry_not_recording", "traces")
|
|
323
|
+
return span, trace.set_span_in_context(span, ctx)
|
|
324
|
+
|
|
325
|
+
def _ignored(self, request: IncomingRequest) -> bool:
|
|
326
|
+
"""A hook that raises never fails the request: it is reported, and recorded."""
|
|
327
|
+
hook = self._runtime.options.input.ignore_incoming_request
|
|
328
|
+
if hook is None:
|
|
329
|
+
return False
|
|
330
|
+
try:
|
|
331
|
+
return bool(hook(request))
|
|
332
|
+
except Exception: # noqa: BLE001 - the application's check must never fail its request
|
|
333
|
+
self._runtime.report("ignore_failed", "context")
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
def _location(self, request: IncomingRequest) -> Attributes:
|
|
337
|
+
hook = self._runtime.options.input.incoming_location
|
|
338
|
+
if hook is None:
|
|
339
|
+
return {}
|
|
340
|
+
try:
|
|
341
|
+
location = hook(request)
|
|
342
|
+
except Exception: # noqa: BLE001 - the application's lookup must never fail its request
|
|
343
|
+
self._runtime.report("location_failed", "context")
|
|
344
|
+
return {}
|
|
345
|
+
if location is None:
|
|
346
|
+
return {}
|
|
347
|
+
places = (location.country, location.region, location.city)
|
|
348
|
+
if (
|
|
349
|
+
any(place is not None and not isinstance(place, str) for place in places) # pyright: ignore[reportUnnecessaryIsInstance]
|
|
350
|
+
or (location.country is not None and _COUNTRY.fullmatch(location.country) is None)
|
|
351
|
+
or location.source not in LOCATION_SOURCES
|
|
352
|
+
):
|
|
353
|
+
self._runtime.report("location_failed", "context")
|
|
354
|
+
return {}
|
|
355
|
+
attributes: Attributes = {}
|
|
356
|
+
for name, value in zip(("country", "region", "city"), places, strict=True):
|
|
357
|
+
text = "" if value is None else bounded(value, 128)
|
|
358
|
+
if text:
|
|
359
|
+
attributes[f"agentrail.client.location.{name}"] = text
|
|
360
|
+
if attributes:
|
|
361
|
+
attributes["agentrail.client.location.source"] = location.source
|
|
362
|
+
return attributes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""The signed trace handoff, keyed hashes and response markers.
|
|
3
|
+
|
|
4
|
+
Byte-compatible with agentrail-core (JavaScript) and agentrail-go: the same key
|
|
5
|
+
ID, the same identity hash, the same handoff payload and token, and the same
|
|
6
|
+
marker reading. ``tests/test_interoperability.py`` holds this to the shared
|
|
7
|
+
vectors in ``testdata/interoperability``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from ._text import js_json, well_formed
|
|
18
|
+
|
|
19
|
+
HANDOFF_HEADER = "x-agentrail-trace-handoff"
|
|
20
|
+
# Headers a managed request never forwards as received.
|
|
21
|
+
OWNED_HEADERS = ("traceparent", "tracestate", "baggage", "sentry-trace", HANDOFF_HEADER)
|
|
22
|
+
MAX_AGE_MS = 60_000
|
|
23
|
+
_MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
24
|
+
|
|
25
|
+
_HEX16 = re.compile(r"[0-9a-f]{16}")
|
|
26
|
+
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
27
|
+
_TIME = re.compile(r"0|[1-9][0-9]{0,15}")
|
|
28
|
+
_TRACEPARENT = re.compile(r"00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})")
|
|
29
|
+
_INVALID_TRACE = "0" * 32
|
|
30
|
+
_INVALID_SPAN = "0" * 16
|
|
31
|
+
# ECMAScript String.prototype.trim whitespace, which core's parser applies.
|
|
32
|
+
_JS_WHITESPACE = "".join(
|
|
33
|
+
chr(code)
|
|
34
|
+
for code in (
|
|
35
|
+
*range(0x09, 0x0E),
|
|
36
|
+
0x20,
|
|
37
|
+
0xA0,
|
|
38
|
+
0x1680,
|
|
39
|
+
*range(0x2000, 0x200B),
|
|
40
|
+
0x2028,
|
|
41
|
+
0x2029,
|
|
42
|
+
0x202F,
|
|
43
|
+
0x205F,
|
|
44
|
+
0x3000,
|
|
45
|
+
0xFEFF,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
KELVIN_SIGN, LONG_S = chr(0x212A), chr(0x017F)
|
|
49
|
+
_METHOD_SYMBOLS = frozenset("!#$%&'*+.^_`|~-")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def key_id(hash_key: str) -> str:
|
|
53
|
+
"""The first 16 lowercase hex characters of the key's SHA-256."""
|
|
54
|
+
return hashlib.sha256(hash_key.encode("utf-8")).hexdigest()[:16]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def keyed_hash(hash_key: str, value: str) -> str:
|
|
58
|
+
"""HMAC-SHA256 of the value under the workspace key, first 32 hex characters."""
|
|
59
|
+
return _mac(hash_key, value.encode("utf-8")).hex()[:32]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _mac(hash_key: str, payload: bytes) -> bytes:
|
|
63
|
+
return hmac.new(hash_key.encode("utf-8"), payload, hashlib.sha256).digest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _js_trim(value: str) -> str:
|
|
67
|
+
return value.strip(_JS_WHITESPACE)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class TraceParent:
|
|
72
|
+
trace_id: int
|
|
73
|
+
span_id: int
|
|
74
|
+
flags: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_traceparent(header: str | None) -> TraceParent | None:
|
|
78
|
+
"""Version-00 ``traceparent``, as core reads it: trimmed, lowercase, no zero IDs."""
|
|
79
|
+
if header is None:
|
|
80
|
+
return None
|
|
81
|
+
match = _TRACEPARENT.fullmatch(_js_trim(header))
|
|
82
|
+
if match is None:
|
|
83
|
+
return None
|
|
84
|
+
trace_hex, span_hex, flags = match.groups()
|
|
85
|
+
if trace_hex == _INVALID_TRACE or span_hex == _INVALID_SPAN:
|
|
86
|
+
return None
|
|
87
|
+
return TraceParent(int(trace_hex, 16), int(span_hex, 16), int(flags, 16))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_traceparent(trace_id: int, span_id: int, flags: int) -> str:
|
|
91
|
+
return f"00-{trace_id:032x}-{span_id:016x}-{flags & 0xFF:02x}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _valid_method(method: str) -> bool:
|
|
95
|
+
if not 1 <= len(method) <= 64:
|
|
96
|
+
return False
|
|
97
|
+
for character in method:
|
|
98
|
+
# JavaScript /[a-z]/iu also folds the Kelvin sign and the long s.
|
|
99
|
+
if (
|
|
100
|
+
character in (KELVIN_SIGN, LONG_S)
|
|
101
|
+
or ("a" <= character <= "z")
|
|
102
|
+
or ("A" <= character <= "Z")
|
|
103
|
+
or ("0" <= character <= "9")
|
|
104
|
+
or character in _METHOD_SYMBOLS
|
|
105
|
+
):
|
|
106
|
+
continue
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _valid_request(method: str, target: str) -> bool:
|
|
112
|
+
return (
|
|
113
|
+
_valid_method(method)
|
|
114
|
+
and target.startswith("/")
|
|
115
|
+
and well_formed(target)
|
|
116
|
+
and not any(character in target for character in "\x00\r\n#")
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _payload(hash_key_id: str, issued_at: int, traceparent: str, method: str, target: str) -> bytes:
|
|
121
|
+
body = js_json([1, hash_key_id, issued_at, traceparent, method, target])
|
|
122
|
+
return f"agentrail.trace-handoff\x00{body}".encode()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class HandoffInputError(ValueError):
|
|
126
|
+
"""The values to sign do not form a valid handoff."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sign(hash_key: str, traceparent: str, method: str, target: str, issued_at: int) -> str:
|
|
130
|
+
"""A token binding the trace context to the exact method and wire target."""
|
|
131
|
+
if (
|
|
132
|
+
not well_formed(hash_key)
|
|
133
|
+
or not _valid_request(method, target)
|
|
134
|
+
or not 0 <= issued_at <= _MAX_SAFE_INTEGER
|
|
135
|
+
or traceparent != _js_trim(traceparent)
|
|
136
|
+
or parse_traceparent(traceparent) is None
|
|
137
|
+
):
|
|
138
|
+
raise HandoffInputError
|
|
139
|
+
identifier = key_id(hash_key)
|
|
140
|
+
signature = _mac(hash_key, _payload(identifier, issued_at, traceparent, method, target))
|
|
141
|
+
return f"ah1.{identifier}.{issued_at}.{traceparent}.{signature.hex()}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Refused(Exception):
|
|
145
|
+
"""Why a handoff did not verify. The reason is one of core's refusal names."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, reason: str) -> None:
|
|
148
|
+
super().__init__(reason)
|
|
149
|
+
self.reason = reason
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def verify(
|
|
153
|
+
hash_key: str,
|
|
154
|
+
token: str | None,
|
|
155
|
+
traceparent: str | None,
|
|
156
|
+
method: str,
|
|
157
|
+
target: str,
|
|
158
|
+
now: int,
|
|
159
|
+
) -> TraceParent:
|
|
160
|
+
"""The remote parent of a valid handoff; raises ``Refused`` otherwise."""
|
|
161
|
+
if token is None or token == "":
|
|
162
|
+
raise Refused("missing")
|
|
163
|
+
if len(token) > 256 or not _valid_request(method, target) or not well_formed(hash_key):
|
|
164
|
+
raise Refused("malformed")
|
|
165
|
+
parts = token.split(".")
|
|
166
|
+
if (
|
|
167
|
+
len(parts) != 5
|
|
168
|
+
or parts[0] != "ah1"
|
|
169
|
+
or not _HEX16.fullmatch(parts[1])
|
|
170
|
+
or not _TIME.fullmatch(parts[2])
|
|
171
|
+
or not _HEX64.fullmatch(parts[4])
|
|
172
|
+
):
|
|
173
|
+
raise Refused("malformed")
|
|
174
|
+
issued_at = int(parts[2])
|
|
175
|
+
parent = parse_traceparent(parts[3]) if parts[3] == _js_trim(parts[3]) else None
|
|
176
|
+
if issued_at > _MAX_SAFE_INTEGER or parent is None:
|
|
177
|
+
raise Refused("malformed")
|
|
178
|
+
if parts[1] != key_id(hash_key):
|
|
179
|
+
raise Refused("key_mismatch")
|
|
180
|
+
if parts[3] != traceparent:
|
|
181
|
+
raise Refused("context_mismatch")
|
|
182
|
+
age = now - issued_at
|
|
183
|
+
if age < 0:
|
|
184
|
+
raise Refused("future")
|
|
185
|
+
if age > MAX_AGE_MS:
|
|
186
|
+
raise Refused("expired")
|
|
187
|
+
expected = _mac(hash_key, _payload(parts[1], issued_at, parts[3], method, target))
|
|
188
|
+
if not hmac.compare_digest(bytes.fromhex(parts[4]), expected):
|
|
189
|
+
raise Refused("signature_invalid")
|
|
190
|
+
return parent
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def valid_marker(raw: str) -> bool:
|
|
197
|
+
"""The collector's marker reading: Crockford folding, then a Luhn mod-32 check."""
|
|
198
|
+
# str.upper applies full Unicode case mapping, as String.prototype.toUpperCase does.
|
|
199
|
+
text = raw.upper().replace("-", "").replace("I", "1").replace("L", "1").replace("O", "0")
|
|
200
|
+
if len(text) not in (14, 16):
|
|
201
|
+
return False
|
|
202
|
+
values: list[int] = []
|
|
203
|
+
for character in text:
|
|
204
|
+
value = _ALPHABET.find(character)
|
|
205
|
+
if value < 0:
|
|
206
|
+
return False
|
|
207
|
+
values.append(value)
|
|
208
|
+
check = values.pop()
|
|
209
|
+
total, factor = 0, 2
|
|
210
|
+
for value in reversed(values):
|
|
211
|
+
addend = factor * value
|
|
212
|
+
total += addend // 32 + addend % 32
|
|
213
|
+
factor = 1 if factor == 2 else 2
|
|
214
|
+
return check == (32 - total % 32) % 32
|