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
|
+
"""Standalone startup: Agentrail owns the process's OpenTelemetry providers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from opentelemetry import _logs as otel_logs
|
|
10
|
+
from opentelemetry import propagate, trace
|
|
11
|
+
from opentelemetry._logs._internal import ProxyLoggerProvider # pyright: ignore[reportPrivateImportUsage]
|
|
12
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
13
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
14
|
+
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
|
15
|
+
|
|
16
|
+
from ._client import Client
|
|
17
|
+
from ._options import ConfigurationError, Options
|
|
18
|
+
from ._processors import Processors
|
|
19
|
+
|
|
20
|
+
_lock = threading.Lock()
|
|
21
|
+
_installed: tuple[Options, Client] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def init(options: Options) -> Client:
|
|
25
|
+
"""Install Agentrail's tracer provider, logger provider and propagator globally.
|
|
26
|
+
|
|
27
|
+
Call once at startup, before the application serves requests. The
|
|
28
|
+
providers drain when the interpreter exits; call ``shutdown`` yourself after
|
|
29
|
+
your server stops accepting requests to bound it. Calling it
|
|
30
|
+
again with the same options object returns the same client. If the process
|
|
31
|
+
already has an OpenTelemetry provider, build it with ``create_processors``
|
|
32
|
+
instead.
|
|
33
|
+
"""
|
|
34
|
+
global _installed
|
|
35
|
+
with _lock:
|
|
36
|
+
if _installed is not None:
|
|
37
|
+
if _installed[0] is not options:
|
|
38
|
+
raise ConfigurationError("Agentrail is already initialized. Reuse the original options and client.")
|
|
39
|
+
return _installed[1]
|
|
40
|
+
if not isinstance(trace.get_tracer_provider(), trace.ProxyTracerProvider):
|
|
41
|
+
raise ConfigurationError(
|
|
42
|
+
"A tracer provider is already installed. Wire create_processors into its construction instead."
|
|
43
|
+
)
|
|
44
|
+
if not isinstance(otel_logs.get_logger_provider(), ProxyLoggerProvider):
|
|
45
|
+
raise ConfigurationError(
|
|
46
|
+
"A logger provider is already installed. Use create_processors with the existing setup."
|
|
47
|
+
)
|
|
48
|
+
processors = Processors(options if options.propagator is not None else _with_base(options))
|
|
49
|
+
provider = TracerProvider(sampler=processors.sampler(ALWAYS_ON))
|
|
50
|
+
provider.add_span_processor(processors.span_processor)
|
|
51
|
+
logger = LoggerProvider()
|
|
52
|
+
logger.add_log_record_processor(processors.log_processor)
|
|
53
|
+
client = processors.bind(provider)
|
|
54
|
+
trace.set_tracer_provider(provider)
|
|
55
|
+
otel_logs.set_logger_provider(logger)
|
|
56
|
+
propagate.set_global_textmap(processors.propagator)
|
|
57
|
+
client._on_shutdown.extend((provider.shutdown, logger.shutdown)) # pyright: ignore[reportPrivateUsage]
|
|
58
|
+
_installed = (options, client)
|
|
59
|
+
return client
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _with_base(options: Options) -> Options:
|
|
63
|
+
"""Unmanaged work keeps the process's existing propagator."""
|
|
64
|
+
return replace(options, propagator=propagate.get_global_textmap())
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Public configuration and evidence types, and their validation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Literal
|
|
12
|
+
from urllib.parse import SplitResult, urlsplit
|
|
13
|
+
|
|
14
|
+
from ._text import valid_text, well_formed
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from opentelemetry.propagators.textmap import TextMapPropagator
|
|
18
|
+
|
|
19
|
+
PrincipalKind = Literal["user", "agent_registration", "agent_instance", "oauth_client", "api_key"]
|
|
20
|
+
PRINCIPAL_KINDS: frozenset[str] = frozenset(("user", "agent_registration", "agent_instance", "oauth_client", "api_key"))
|
|
21
|
+
Surface = Literal["http", "mcp", "auth", "custom", "a2a", "genai", "payment", "edge", "firewall", "browser"]
|
|
22
|
+
SURFACES: frozenset[str] = frozenset(
|
|
23
|
+
("http", "mcp", "auth", "custom", "a2a", "genai", "payment", "edge", "firewall", "browser")
|
|
24
|
+
)
|
|
25
|
+
LocationSource = Literal["dbip", "cloudflare", "vercel", "source"]
|
|
26
|
+
LOCATION_SOURCES: frozenset[str] = frozenset(("dbip", "cloudflare", "vercel", "source"))
|
|
27
|
+
|
|
28
|
+
CaptureCode = Literal[
|
|
29
|
+
"queue_full",
|
|
30
|
+
"export_failed",
|
|
31
|
+
"deadline",
|
|
32
|
+
"closed",
|
|
33
|
+
"handoff_refused",
|
|
34
|
+
"entry_not_recording",
|
|
35
|
+
"location_failed",
|
|
36
|
+
"ignore_failed",
|
|
37
|
+
"identity_outside_request",
|
|
38
|
+
"invalid_performance",
|
|
39
|
+
]
|
|
40
|
+
Signal = Literal["traces", "logs", "context"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfigurationError(ValueError):
|
|
44
|
+
"""Options, identity or milestone input the SDK cannot use."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CaptureError(Exception):
|
|
48
|
+
"""Lost records or refused context. Holds no customer data.
|
|
49
|
+
|
|
50
|
+
Passed to ``on_error``; the SDK never raises it into the application.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, code: CaptureCode, signal: Signal, count: int) -> None:
|
|
54
|
+
super().__init__(f"Agentrail {signal} capture: {code} ({count} records).")
|
|
55
|
+
self.code: CaptureCode = code
|
|
56
|
+
self.signal: Signal = signal
|
|
57
|
+
self.count = count
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, kw_only=True)
|
|
61
|
+
class CollectorOptions:
|
|
62
|
+
"""The collector's OTLP base URL and a backend source secret."""
|
|
63
|
+
|
|
64
|
+
endpoint: str
|
|
65
|
+
secret: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, kw_only=True)
|
|
69
|
+
class QueueOptions:
|
|
70
|
+
"""Per-signal delivery bounds. Times are seconds."""
|
|
71
|
+
|
|
72
|
+
max_records: int = 2048
|
|
73
|
+
batch_size: int = 128
|
|
74
|
+
scheduled_delay: float = 1.0
|
|
75
|
+
export_timeout: float = 5.0
|
|
76
|
+
flush_timeout: float = 10.0
|
|
77
|
+
shutdown_timeout: float = 10.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, kw_only=True)
|
|
81
|
+
class LogsOptions:
|
|
82
|
+
"""Message text and other fields leave only when named in ``include_fields``."""
|
|
83
|
+
|
|
84
|
+
include_fields: Sequence[str] = ()
|
|
85
|
+
exclude_uncorrelated: bool = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, kw_only=True)
|
|
89
|
+
class IPLocation:
|
|
90
|
+
"""An approximate network exit, never a verified place. ``None`` is unknown."""
|
|
91
|
+
|
|
92
|
+
country: str | None = None
|
|
93
|
+
region: str | None = None
|
|
94
|
+
city: str | None = None
|
|
95
|
+
source: LocationSource = "source"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class IncomingRequest:
|
|
100
|
+
"""What the hooks see of an incoming request.
|
|
101
|
+
|
|
102
|
+
``headers`` has lowercase names; a repeated header's values are joined with
|
|
103
|
+
``", "``. ``client`` is the peer address the server saw, not a forwarded
|
|
104
|
+
header. ``raw`` is the ASGI scope or the WSGI environ.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
method: str
|
|
108
|
+
target: str
|
|
109
|
+
scheme: str
|
|
110
|
+
host: str
|
|
111
|
+
headers: Mapping[str, str]
|
|
112
|
+
client: str | None
|
|
113
|
+
raw: Mapping[str, object]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, kw_only=True)
|
|
117
|
+
class Options:
|
|
118
|
+
collector: CollectorOptions
|
|
119
|
+
hash_key: str
|
|
120
|
+
service_name: str
|
|
121
|
+
service_instance_id: str | None = None
|
|
122
|
+
outbound_origins: Sequence[str] = ()
|
|
123
|
+
ignore_incoming_request: Callable[[IncomingRequest], bool] | None = None
|
|
124
|
+
incoming_location: Callable[[IncomingRequest], IPLocation | None] | None = None
|
|
125
|
+
logs: LogsOptions = field(default_factory=LogsOptions)
|
|
126
|
+
queue: QueueOptions = field(default_factory=QueueOptions)
|
|
127
|
+
propagator: TextMapPropagator | None = None
|
|
128
|
+
on_error: Callable[[CaptureError], None] | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, kw_only=True)
|
|
132
|
+
class VerifiedIdentity:
|
|
133
|
+
"""An identity the application has already authenticated."""
|
|
134
|
+
|
|
135
|
+
kind: PrincipalKind
|
|
136
|
+
id: str
|
|
137
|
+
acting_user_id: str | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, kw_only=True)
|
|
141
|
+
class McpContext:
|
|
142
|
+
method: str
|
|
143
|
+
client: str | None = None
|
|
144
|
+
client_version: str | None = None
|
|
145
|
+
protocol: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, kw_only=True)
|
|
149
|
+
class AuthorizedOperation:
|
|
150
|
+
"""A business attempt the application has authorized.
|
|
151
|
+
|
|
152
|
+
``instance_ref`` is hashed locally and never exported.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
namespace_id: str
|
|
156
|
+
instance_ref: str
|
|
157
|
+
role: Literal["start", "progress", "terminal"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, kw_only=True)
|
|
161
|
+
class PluginInvocation:
|
|
162
|
+
"""``invocation_id`` is opaque: never tool arguments or a customer identifier."""
|
|
163
|
+
|
|
164
|
+
binding_id: str
|
|
165
|
+
invocation_id: str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, kw_only=True)
|
|
169
|
+
class PerformanceContext:
|
|
170
|
+
"""Workflow and plugin evidence. Supply ``operation`` with ``event_name``,
|
|
171
|
+
``operation`` with ``plugin``, or ``plugin`` alone."""
|
|
172
|
+
|
|
173
|
+
environment: Literal["production", "test"]
|
|
174
|
+
operation: AuthorizedOperation | None = None
|
|
175
|
+
event_name: str | None = None
|
|
176
|
+
plugin: PluginInvocation | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True, kw_only=True)
|
|
180
|
+
class MilestoneContext:
|
|
181
|
+
surface: Surface
|
|
182
|
+
kind: str
|
|
183
|
+
mcp: McpContext | None = None
|
|
184
|
+
performance: PerformanceContext | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class Resolved:
|
|
189
|
+
"""Validated options with derived values."""
|
|
190
|
+
|
|
191
|
+
input: Options
|
|
192
|
+
endpoint: str
|
|
193
|
+
endpoint_origin: str
|
|
194
|
+
endpoint_path: str
|
|
195
|
+
instance: str
|
|
196
|
+
origins: frozenset[str]
|
|
197
|
+
queue: QueueOptions
|
|
198
|
+
include_fields: tuple[str, ...]
|
|
199
|
+
|
|
200
|
+
def report(self, error: CaptureError) -> None:
|
|
201
|
+
from ._suppression import reporting
|
|
202
|
+
|
|
203
|
+
with reporting():
|
|
204
|
+
callback = self.input.on_error
|
|
205
|
+
if callback is None:
|
|
206
|
+
print(error, file=sys.stderr)
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
callback(error)
|
|
210
|
+
except Exception as callback_error: # noqa: BLE001 - an application callback may raise anything
|
|
211
|
+
print("Agentrail on_error callback failed:", repr(callback_error), error, file=sys.stderr)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def origin_of(parts: SplitResult) -> str:
|
|
215
|
+
"""Lowercase scheme and host, IPv6 in brackets, default port omitted."""
|
|
216
|
+
scheme = parts.scheme.lower()
|
|
217
|
+
host = (parts.hostname or "").lower()
|
|
218
|
+
if ":" in host:
|
|
219
|
+
host = f"[{host}]"
|
|
220
|
+
port = parts.port
|
|
221
|
+
if port is not None and not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)):
|
|
222
|
+
host = f"{host}:{port}"
|
|
223
|
+
return f"{scheme}://{host}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _url(value: str) -> SplitResult | None:
|
|
227
|
+
try:
|
|
228
|
+
parts = urlsplit(value)
|
|
229
|
+
_ = parts.port # raises for an invalid port
|
|
230
|
+
except ValueError:
|
|
231
|
+
return None
|
|
232
|
+
if parts.scheme.lower() not in ("http", "https") or not parts.hostname:
|
|
233
|
+
return None
|
|
234
|
+
if parts.username is not None or parts.password is not None:
|
|
235
|
+
return None
|
|
236
|
+
if parts.query or parts.fragment or "?" in value or "#" in value:
|
|
237
|
+
return None
|
|
238
|
+
return parts
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resolve(options: Options) -> Resolved:
|
|
242
|
+
endpoint = _url(options.collector.endpoint)
|
|
243
|
+
if endpoint is None:
|
|
244
|
+
raise ConfigurationError("Use an absolute http(s) collector base URL without credentials, query or fragment.")
|
|
245
|
+
secret = options.collector.secret
|
|
246
|
+
if (
|
|
247
|
+
not options.hash_key
|
|
248
|
+
or not well_formed(options.hash_key)
|
|
249
|
+
or not secret
|
|
250
|
+
or any(character in secret for character in " \t\r\n")
|
|
251
|
+
or not options.service_name
|
|
252
|
+
or not valid_text(options.service_name, 4096)
|
|
253
|
+
or (options.service_instance_id is not None and not options.service_instance_id)
|
|
254
|
+
or (options.service_instance_id is not None and not valid_text(options.service_instance_id, 4096))
|
|
255
|
+
):
|
|
256
|
+
raise ConfigurationError(
|
|
257
|
+
"Configure a source secret, a UTF-8 workspace hash key and a service name "
|
|
258
|
+
"(and instance ID) of well-formed text without NUL, at most 4096 characters."
|
|
259
|
+
)
|
|
260
|
+
queue = options.queue
|
|
261
|
+
integers = (queue.max_records, queue.batch_size)
|
|
262
|
+
times = (queue.scheduled_delay, queue.export_timeout, queue.flush_timeout, queue.shutdown_timeout)
|
|
263
|
+
if (
|
|
264
|
+
any(isinstance(value, bool) or value < 1 for value in integers)
|
|
265
|
+
or any(isinstance(value, bool) or not value > 0 for value in times)
|
|
266
|
+
or queue.batch_size > queue.max_records
|
|
267
|
+
or queue.batch_size > 10_000
|
|
268
|
+
or queue.export_timeout > min(queue.flush_timeout, queue.shutdown_timeout)
|
|
269
|
+
):
|
|
270
|
+
raise ConfigurationError(
|
|
271
|
+
"Use positive queue limits, batch_size <= max_records and 10000, "
|
|
272
|
+
"and flush/shutdown timeouts >= export_timeout."
|
|
273
|
+
)
|
|
274
|
+
origins: set[str] = set()
|
|
275
|
+
for raw in options.outbound_origins:
|
|
276
|
+
parts = _url(raw)
|
|
277
|
+
if parts is None or parts.path != "" or origin_of(parts) != raw:
|
|
278
|
+
raise ConfigurationError("outbound_origins must contain exact canonical http(s) origins.")
|
|
279
|
+
origins.add(raw)
|
|
280
|
+
for name in options.logs.include_fields:
|
|
281
|
+
if not isinstance(name, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
282
|
+
raise ConfigurationError("logs.include_fields must contain field names.")
|
|
283
|
+
trimmed = options.collector.endpoint.rstrip("/")
|
|
284
|
+
return Resolved(
|
|
285
|
+
input=options,
|
|
286
|
+
endpoint=trimmed,
|
|
287
|
+
endpoint_origin=origin_of(endpoint),
|
|
288
|
+
endpoint_path=endpoint.path.rstrip("/"),
|
|
289
|
+
instance=options.service_instance_id or uuid.uuid4().hex,
|
|
290
|
+
origins=frozenset(origins),
|
|
291
|
+
queue=queue,
|
|
292
|
+
include_fields=tuple(options.logs.include_fields),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
|
297
|
+
_EVENT = re.compile(r"[a-zA-Z][a-zA-Z0-9_.:-]*")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def valid_uuid(value: str) -> bool:
|
|
301
|
+
return _UUID.fullmatch(value) is not None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def valid_event_name(value: str) -> bool:
|
|
305
|
+
return len(value) <= 120 and _EVENT.fullmatch(value) is not None
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""What an outgoing call carries, decided by its actual destination."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from opentelemetry.trace import Span, SpanKind, StatusCode
|
|
11
|
+
|
|
12
|
+
from ._handoff import HANDOFF_HEADER, HandoffInputError, format_traceparent, sign
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ._client import Client
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def origin(scheme: str, host: str, port: int | None) -> str:
|
|
19
|
+
scheme = scheme.lower()
|
|
20
|
+
host = host.lower()
|
|
21
|
+
if ":" in host and not host.startswith("["):
|
|
22
|
+
host = f"[{host}]"
|
|
23
|
+
if port is not None and not ((scheme == "http" and port == 80) or (scheme == "https" and port == 443)):
|
|
24
|
+
host = f"{host}:{port}"
|
|
25
|
+
return f"{scheme}://{host}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Outgoing:
|
|
30
|
+
"""``span`` is None when the call is not part of a managed operation."""
|
|
31
|
+
|
|
32
|
+
span: Span | None
|
|
33
|
+
headers: dict[str, str] = field(default_factory=lambda: {})
|
|
34
|
+
strip: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def begin(client: Client, method: str, destination: str, path: str, target: str, url: str) -> Outgoing:
|
|
38
|
+
"""Owned headers are always stripped from managed calls; only exact allowed
|
|
39
|
+
origins receive a newly signed handoff."""
|
|
40
|
+
runtime = client._runtime # pyright: ignore[reportPrivateUsage]
|
|
41
|
+
if not client._managed(): # pyright: ignore[reportPrivateUsage]
|
|
42
|
+
return Outgoing(None)
|
|
43
|
+
options = runtime.options
|
|
44
|
+
if destination == options.endpoint_origin and path in (
|
|
45
|
+
f"{options.endpoint_path}/v1/traces",
|
|
46
|
+
f"{options.endpoint_path}/v1/logs",
|
|
47
|
+
):
|
|
48
|
+
return Outgoing(None, strip=True)
|
|
49
|
+
tracer = client._tracer # pyright: ignore[reportPrivateUsage]
|
|
50
|
+
span = tracer.start_span(
|
|
51
|
+
f"HTTP {method}",
|
|
52
|
+
kind=SpanKind.CLIENT,
|
|
53
|
+
attributes={"http.request.method": method, "url.full": url},
|
|
54
|
+
)
|
|
55
|
+
outgoing = Outgoing(span, strip=True)
|
|
56
|
+
if destination in options.origins:
|
|
57
|
+
context = span.get_span_context()
|
|
58
|
+
parent = format_traceparent(context.trace_id, context.span_id, context.trace_flags)
|
|
59
|
+
try:
|
|
60
|
+
token = sign(options.input.hash_key, parent, method, target, time.time_ns() // 1_000_000)
|
|
61
|
+
except HandoffInputError:
|
|
62
|
+
runtime.report("handoff_refused", "context")
|
|
63
|
+
else:
|
|
64
|
+
outgoing.headers = {"traceparent": parent, HANDOFF_HEADER: token}
|
|
65
|
+
return outgoing
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def finish(span: Span, status: int | None, error: BaseException | None) -> None:
|
|
69
|
+
if error is not None:
|
|
70
|
+
span.set_status(StatusCode.ERROR)
|
|
71
|
+
span.set_attribute("error.type", "TransportError")
|
|
72
|
+
if status is not None:
|
|
73
|
+
span.set_attribute("http.response.status_code", status)
|
|
74
|
+
if status >= 400:
|
|
75
|
+
span.set_status(StatusCode.ERROR)
|
|
76
|
+
span.end()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Workflow and plugin evidence on a milestone, as agentrail-node emits it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
|
|
9
|
+
from ._options import ConfigurationError, PerformanceContext, valid_event_name, valid_uuid
|
|
10
|
+
from ._runtime import Attributes
|
|
11
|
+
from ._text import js_json, utf16_length, well_formed
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def operation_hash(hash_key: str, namespace_id: str, instance_ref: str) -> str:
|
|
15
|
+
"""HMAC-SHA256 of ``["agentrail.operation", namespace, reference]`` as JSON, full hex."""
|
|
16
|
+
payload = js_json(["agentrail.operation", namespace_id, instance_ref]).encode("utf-8")
|
|
17
|
+
return hmac.new(hash_key.encode("utf-8"), payload, hashlib.sha256).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def performance_attributes(performance: PerformanceContext, hash_key: str) -> Attributes:
|
|
21
|
+
"""Only normalized, allowlisted evidence. Raises ``ConfigurationError`` for invalid input."""
|
|
22
|
+
if performance.environment not in ("production", "test"):
|
|
23
|
+
raise ConfigurationError("Use the production or test environment.")
|
|
24
|
+
operation, plugin = performance.operation, performance.plugin
|
|
25
|
+
if plugin is None and (operation is None or performance.event_name is None):
|
|
26
|
+
raise ConfigurationError("Supply an operation with its event name, a plugin invocation, or both.")
|
|
27
|
+
if operation is None and performance.event_name is not None:
|
|
28
|
+
raise ConfigurationError("An event name needs its authorized operation.")
|
|
29
|
+
attributes: Attributes = {"agentrail.environment": performance.environment}
|
|
30
|
+
if plugin is not None:
|
|
31
|
+
invocation = plugin.invocation_id
|
|
32
|
+
if (
|
|
33
|
+
not valid_uuid(plugin.binding_id)
|
|
34
|
+
or not 1 <= utf16_length(invocation) <= 256
|
|
35
|
+
or not well_formed(invocation)
|
|
36
|
+
or "\x00" in invocation
|
|
37
|
+
):
|
|
38
|
+
raise ConfigurationError("Use a canonical binding ID and an opaque invocation ID of 1-256 characters.")
|
|
39
|
+
attributes["agentrail.plugin.binding_id"] = plugin.binding_id
|
|
40
|
+
attributes["agentrail.plugin.invocation_id"] = invocation
|
|
41
|
+
if operation is not None:
|
|
42
|
+
if (
|
|
43
|
+
not valid_uuid(operation.namespace_id)
|
|
44
|
+
or not operation.instance_ref
|
|
45
|
+
or not well_formed(operation.instance_ref)
|
|
46
|
+
or operation.role not in ("start", "progress", "terminal")
|
|
47
|
+
):
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
"Use a canonical namespace ID, a nonempty well-formed operation reference and a role."
|
|
50
|
+
)
|
|
51
|
+
attributes["agentrail.operation.namespace_id"] = operation.namespace_id
|
|
52
|
+
attributes["agentrail.operation.hash"] = operation_hash(
|
|
53
|
+
hash_key, operation.namespace_id, operation.instance_ref
|
|
54
|
+
)
|
|
55
|
+
attributes["agentrail.workflow.role"] = operation.role
|
|
56
|
+
if performance.event_name is not None:
|
|
57
|
+
if not valid_event_name(performance.event_name):
|
|
58
|
+
raise ConfigurationError(
|
|
59
|
+
"Use a business event name of 1-120 letters, digits, dots, underscores, "
|
|
60
|
+
"colons or hyphens, starting with a letter."
|
|
61
|
+
)
|
|
62
|
+
attributes["agentrail.workflow.event_name"] = performance.event_name
|
|
63
|
+
return attributes
|