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,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