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,255 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """Sanitized copies: what leaves the process, built from an allowlist.
3
+
4
+ The customer's spans and log records are never changed; the queue only ever
5
+ holds these copies.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import uuid
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass, field
14
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
15
+
16
+ from opentelemetry._logs import LogRecord, SeverityNumber
17
+ from opentelemetry.context import Context
18
+ from opentelemetry.sdk._logs import ReadableLogRecord
19
+ from opentelemetry.sdk.trace import ReadableSpan
20
+ from opentelemetry.trace import (
21
+ INVALID_SPAN,
22
+ Link,
23
+ NonRecordingSpan,
24
+ SpanContext,
25
+ SpanKind,
26
+ Status,
27
+ TraceFlags,
28
+ set_span_in_context,
29
+ )
30
+ from opentelemetry.util.types import AttributeValue
31
+
32
+ from ._handoff import valid_marker
33
+ from ._runtime import Attributes, Runtime, SpanMeta
34
+ from ._text import bounded, js_json, utf16_length, valid_text
35
+
36
+ _LIMIT = 4096
37
+
38
+
39
+ def clean_context(context: SpanContext) -> SpanContext:
40
+ """The IDs and flags only: trace state never leaves."""
41
+ return SpanContext(
42
+ trace_id=context.trace_id,
43
+ span_id=context.span_id,
44
+ is_remote=context.is_remote,
45
+ trace_flags=TraceFlags(context.trace_flags),
46
+ )
47
+
48
+
49
+ def sanitized_url(raw: object) -> str | None:
50
+ """An http(s) URL with only valid ``h`` markers in its query; None past 4096 units."""
51
+ if not isinstance(raw, str):
52
+ return None
53
+ try:
54
+ parts = urlsplit(raw)
55
+ port = parts.port
56
+ except ValueError:
57
+ return None
58
+ if parts.scheme.lower() not in ("http", "https") or not parts.hostname:
59
+ return None
60
+ markers = [value for key, value in parse_qsl(parts.query, keep_blank_values=True) if key == "h"]
61
+ host = parts.hostname
62
+ if ":" in host:
63
+ host = f"[{host}]"
64
+ netloc = host if port is None else f"{host}:{port}"
65
+ query = urlencode([("h", marker) for marker in markers if valid_marker(marker)])
66
+ url = urlunsplit((parts.scheme.lower(), netloc, parts.path or "/", query, ""))
67
+ return url if valid_text(url, _LIMIT) else None
68
+
69
+
70
+ def _first(attributes: Mapping[str, AttributeValue], *names: str) -> AttributeValue | None:
71
+ for name in names:
72
+ if name in attributes:
73
+ return attributes[name]
74
+ return None
75
+
76
+
77
+ def copy_span(span: ReadableSpan, meta: SpanMeta, runtime: Runtime) -> ReadableSpan:
78
+ original: Mapping[str, AttributeValue] = span.attributes or {}
79
+ attributes: Attributes = {}
80
+ pairs = {
81
+ "http.request.method": ("http.request.method", "http.method"),
82
+ "http.route": ("http.route",),
83
+ "http.response.status_code": ("http.response.status_code", "http.status_code"),
84
+ "user_agent.original": ("user_agent.original", "http.user_agent"),
85
+ "error.type": ("error.type", "exception.type"),
86
+ }
87
+ for key, names in pairs.items():
88
+ value = _first(original, *names)
89
+ if isinstance(value, str):
90
+ attributes[key] = bounded(value, _LIMIT)
91
+ elif isinstance(value, int) and not isinstance(value, bool):
92
+ attributes[key] = value
93
+ if "error.type" not in attributes:
94
+ for event in span.events:
95
+ kind = (event.attributes or {}).get("exception.type")
96
+ if event.name == "exception" and isinstance(kind, str):
97
+ attributes["error.type"] = bounded(kind, _LIMIT)
98
+ break
99
+ address, path, scheme = original.get("server.address"), original.get("url.path"), original.get("url.scheme")
100
+ incoming = None
101
+ if isinstance(address, str) and isinstance(path, str) and isinstance(scheme, str):
102
+ port = original.get("server.port")
103
+ query = original.get("url.query")
104
+ incoming = f"{scheme}://{address}{f':{port}' if isinstance(port, int) else ''}{path}"
105
+ if isinstance(query, str):
106
+ incoming += f"?{query}"
107
+ url = sanitized_url(_first(original, "url.full", "http.url") or incoming)
108
+ if url is not None:
109
+ attributes["url.full"] = url
110
+
111
+ kind = span.kind
112
+ parent = span.parent
113
+ own = span.instrumentation_scope is not None and span.instrumentation_scope.name == runtime.scope.name
114
+ if kind in (SpanKind.SERVER, SpanKind.CONSUMER) and not own and parent is not None and not parent.is_remote:
115
+ # A library's own server span inside a managed operation (the MCP SDK opens
116
+ # one per message) is supporting work, not a second entry.
117
+ kind = SpanKind.INTERNAL
118
+ mark = meta.mark
119
+ entry = mark is not None or kind in (SpanKind.SERVER, SpanKind.CONSUMER)
120
+ if entry:
121
+ identity = mark.identity if mark is not None and mark.identity is not None else meta.state.principal()
122
+ attributes.update(identity)
123
+ attributes.update(meta.state.location)
124
+ if kind == SpanKind.SERVER:
125
+ attributes["agentrail.ingress.verified"] = meta.state.verified
126
+ method = attributes.get("http.request.method")
127
+ name = f"HTTP {method}" if isinstance(method, str) else "backend.operation"
128
+ if mark is not None:
129
+ name = mark.name
130
+ attributes["agentrail.step"] = True
131
+ attributes["agentrail.step.surface"] = mark.details.surface
132
+ attributes["agentrail.step.kind"] = mark.details.kind
133
+ if mark.performance is not None:
134
+ attributes.update(mark.performance)
135
+ mcp = mark.details.mcp
136
+ if mcp is not None:
137
+ for key, value, limit in (
138
+ ("agentrail.mcp.client.name", mcp.client, 128),
139
+ ("agentrail.mcp.client.version", mcp.client_version, 128),
140
+ ("agentrail.mcp.protocol.version", mcp.protocol, 64),
141
+ ):
142
+ if value:
143
+ attributes[key] = bounded(value, limit)
144
+ links = [Link(clean_context(link.context)) for link in span.links]
145
+ if kind == SpanKind.SERVER:
146
+ links.extend(Link(clean_context(link.context)) for link in meta.state.links)
147
+ context = span.get_span_context()
148
+ assert context is not None # an SDK span always has a context
149
+ return ReadableSpan(
150
+ name=bounded(name, _LIMIT),
151
+ context=clean_context(context),
152
+ parent=None if parent is None else clean_context(parent),
153
+ resource=runtime.resource,
154
+ attributes=attributes,
155
+ events=(),
156
+ links=links,
157
+ kind=kind,
158
+ status=Status(span.status.status_code),
159
+ start_time=span.start_time,
160
+ end_time=span.end_time,
161
+ instrumentation_scope=runtime.scope,
162
+ )
163
+
164
+
165
+ def _scalar(value: object) -> str | int | float | bool | type[_Skip] | None:
166
+ if value is None or isinstance(value, bool):
167
+ return value
168
+ if isinstance(value, str):
169
+ return bounded(value, _LIMIT)
170
+ if isinstance(value, int):
171
+ return value
172
+ if isinstance(value, float):
173
+ return value if math.isfinite(value) else _Skip
174
+ return _Skip
175
+
176
+
177
+ class _Skip:
178
+ """A field that is not a scalar and is left out."""
179
+
180
+
181
+ def _fit(value: str, budget: int) -> str:
182
+ """The longest prefix whose JSON encoding fits ``budget`` UTF-16 units."""
183
+ low, high = 0, len(value)
184
+ while low < high:
185
+ middle = (low + high + 1) // 2
186
+ if utf16_length(js_json(value[:middle])) <= budget:
187
+ low = middle
188
+ else:
189
+ high = middle - 1
190
+ return value[:low]
191
+
192
+
193
+ def selected_body(fields: Mapping[str, object], selected: tuple[str, ...]) -> str | None:
194
+ """A JSON object of the selected scalar fields, in allowlist order, at most 4096 units."""
195
+ result: dict[str, object] = {}
196
+ body: str | None = None
197
+ for key in selected:
198
+ if key not in fields or key in result:
199
+ continue
200
+ scalar = _scalar(fields[key])
201
+ if scalar is _Skip:
202
+ continue
203
+ candidate = {**result, key: scalar}
204
+ encoded = js_json(candidate)
205
+ if utf16_length(encoded) > _LIMIT:
206
+ if not isinstance(scalar, str):
207
+ continue
208
+ overhead = utf16_length(js_json({**result, key: ""})) - 2
209
+ budget = _LIMIT - overhead
210
+ if budget < 2:
211
+ continue
212
+ candidate[key] = _fit(scalar, budget)
213
+ encoded = js_json(candidate)
214
+ result = candidate
215
+ body = encoded
216
+ return body
217
+
218
+
219
+ @dataclass(frozen=True, kw_only=True)
220
+ class StructuredLog:
221
+ """A log entry. ``fields`` reach Agentrail only when named in ``logs.include_fields``."""
222
+
223
+ severity_number: int | None = None
224
+ severity_text: str | None = None
225
+ error_type: str | None = None
226
+ event_name: str | None = None
227
+ fields: Mapping[str, object] = field(default_factory=lambda: {})
228
+
229
+
230
+ def copy_log(
231
+ entry: StructuredLog,
232
+ timestamp: int,
233
+ observed: int,
234
+ span: SpanContext | None,
235
+ runtime: Runtime,
236
+ ) -> ReadableLogRecord:
237
+ attributes: Attributes = {"agentrail.log.id": str(uuid.uuid4())}
238
+ if entry.error_type:
239
+ attributes["error.type"] = bounded(entry.error_type, _LIMIT)
240
+ body = selected_body(entry.fields, runtime.options.include_fields)
241
+ if body is not None:
242
+ attributes["agentrail.log.body_allowed"] = True
243
+ severity = entry.severity_number
244
+ record = LogRecord(
245
+ timestamp=timestamp,
246
+ observed_timestamp=observed,
247
+ # Exact correlation, never the ambient span: an uncorrelated record names none.
248
+ context=set_span_in_context(INVALID_SPAN if span is None else NonRecordingSpan(span), Context()),
249
+ severity_text=None if entry.severity_text is None else bounded(entry.severity_text, 64),
250
+ severity_number=SeverityNumber(severity) if severity is not None and 1 <= severity <= 24 else None,
251
+ body=body,
252
+ attributes=attributes,
253
+ event_name=None if not entry.event_name else bounded(entry.event_name, 256),
254
+ )
255
+ return ReadableLogRecord(record, resource=runtime.resource, instrumentation_scope=runtime.scope)
@@ -0,0 +1,259 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """OpenTelemetry pieces a customer's own providers are built with."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import time
8
+ import weakref
9
+ from collections.abc import Sequence
10
+ from typing import cast
11
+
12
+ import requests
13
+ from opentelemetry import context as otel_context
14
+ from opentelemetry import trace
15
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
16
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
17
+ from opentelemetry.propagators.textmap import (
18
+ CarrierT,
19
+ Getter,
20
+ Setter,
21
+ TextMapPropagator,
22
+ default_getter,
23
+ default_setter,
24
+ )
25
+ from opentelemetry.sdk._logs import LogRecordProcessor, ReadableLogRecord, ReadWriteLogRecord
26
+ from opentelemetry.sdk._logs.export import LogRecordExportResult
27
+ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
28
+ from opentelemetry.sdk.trace import Span as SdkSpan
29
+ from opentelemetry.sdk.trace.export import SpanExportResult
30
+ from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult
31
+ from opentelemetry.trace import Link, SpanKind, TraceState
32
+ from opentelemetry.util.types import Attributes
33
+
34
+ from ._client import Client
35
+ from ._handoff import OWNED_HEADERS
36
+ from ._options import ConfigurationError, Options, resolve
37
+ from ._privacy import StructuredLog, copy_log, copy_span
38
+ from ._queue import ExportQueue
39
+ from ._runtime import Runtime, SpanMeta
40
+ from ._suppression import suppressed
41
+
42
+
43
+ class _SpanProcessor(SpanProcessor):
44
+ def __init__(self, runtime: Runtime, queue: ExportQueue[ReadableSpan]) -> None:
45
+ self._runtime = runtime
46
+ self._queue = queue
47
+
48
+ def on_start(self, span: SdkSpan, parent_context: otel_context.Context | None = None) -> None:
49
+ if suppressed():
50
+ return
51
+ ctx = otel_context.get_current() if parent_context is None else parent_context
52
+ state = self._runtime.state(ctx)
53
+ if state is None:
54
+ return
55
+ context = span.get_span_context()
56
+ self._runtime.remember(context.trace_id, context.span_id, SpanMeta(state, self._runtime.mark(ctx)))
57
+
58
+ def on_end(self, span: ReadableSpan) -> None:
59
+ context = span.get_span_context()
60
+ if context is None:
61
+ return
62
+ meta = self._runtime.forget(context.trace_id, context.span_id)
63
+ if meta is not None:
64
+ self._queue.add(copy_span(span, meta, self._runtime))
65
+
66
+ def shutdown(self) -> None:
67
+ self._queue.shutdown()
68
+
69
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
70
+ self._queue.flush(timeout_millis / 1000)
71
+ return True
72
+
73
+
74
+ class _LogProcessor(LogRecordProcessor):
75
+ """Copies an OTel log record's allowlisted fields; the record itself is untouched."""
76
+
77
+ def __init__(self, runtime: Runtime, queue: ExportQueue[ReadableLogRecord]) -> None:
78
+ self._runtime = runtime
79
+ self._queue = queue
80
+
81
+ def on_emit(self, log_record: ReadWriteLogRecord) -> None:
82
+ if suppressed():
83
+ return
84
+ if self._runtime.options.input.logs.exclude_uncorrelated and self._runtime.state() is None:
85
+ return
86
+ record = log_record.log_record
87
+ fields: dict[str, object] = {}
88
+ if isinstance(record.body, str):
89
+ fields["message"] = record.body
90
+ error_type: str | None = None
91
+ event_name = record.event_name
92
+ for key, value in (record.attributes or {}).items():
93
+ fields[key] = value
94
+ if key in ("error.type", "exception.type") and isinstance(value, str):
95
+ error_type = value
96
+ if key == "event.name" and isinstance(value, str):
97
+ event_name = value
98
+ span = None
99
+ if record.trace_id and record.span_id:
100
+ span = trace.SpanContext(record.trace_id, record.span_id, False, trace.TraceFlags(record.trace_flags or 0))
101
+ now = time.time_ns()
102
+ entry = StructuredLog(
103
+ severity_number=None if record.severity_number is None else record.severity_number.value,
104
+ severity_text=record.severity_text,
105
+ error_type=error_type,
106
+ event_name=event_name,
107
+ fields=fields,
108
+ )
109
+ self._queue.add(copy_log(entry, record.timestamp or now, record.observed_timestamp or now, span, self._runtime))
110
+
111
+ def shutdown(self) -> None:
112
+ self._queue.shutdown()
113
+
114
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
115
+ self._queue.flush(timeout_millis / 1000)
116
+ return True
117
+
118
+
119
+ class _Sampler(Sampler):
120
+ """Promotes a dropped span inside a managed operation to record-only."""
121
+
122
+ def __init__(self, runtime: Runtime, base: Sampler) -> None:
123
+ self._runtime = runtime
124
+ self._base = base
125
+
126
+ def should_sample(
127
+ self,
128
+ parent_context: otel_context.Context | None,
129
+ trace_id: int,
130
+ name: str,
131
+ kind: SpanKind | None = None,
132
+ attributes: Attributes = None,
133
+ links: Sequence[Link] | None = None,
134
+ trace_state: TraceState | None = None,
135
+ ) -> SamplingResult:
136
+ result = self._base.should_sample(parent_context, trace_id, name, kind, attributes, links, trace_state)
137
+ ctx = otel_context.get_current() if parent_context is None else parent_context
138
+ if result.decision == Decision.DROP and not suppressed() and self._runtime.state(ctx) is not None:
139
+ # The SDK starts a span with the sampler's attributes only, and a DROP
140
+ # carries none: keep the ones the span was started with.
141
+ returned = cast(Attributes, result.attributes) # pyright: ignore[reportUnknownMemberType]
142
+ merged = {**(attributes or {}), **(returned or {})}
143
+ return SamplingResult(Decision.RECORD_ONLY, merged, result.trace_state)
144
+ return result
145
+
146
+ def get_description(self) -> str:
147
+ return f"Agentrail({self._base.get_description()})"
148
+
149
+
150
+ class _Propagator(TextMapPropagator):
151
+ """Managed work is never continued from, or injected with, an unsigned header.
152
+
153
+ Unmanaged work goes to the customer's propagator.
154
+ """
155
+
156
+ def __init__(self, runtime: Runtime, base: TextMapPropagator | None) -> None:
157
+ self._runtime = runtime
158
+ self._base = base
159
+
160
+ def extract(
161
+ self,
162
+ carrier: CarrierT,
163
+ context: otel_context.Context | None = None,
164
+ getter: Getter[CarrierT] = default_getter, # type: ignore[assignment]
165
+ ) -> otel_context.Context:
166
+ ctx = otel_context.get_current() if context is None else context
167
+ if self._runtime.state(ctx) is not None or self._base is None:
168
+ return ctx
169
+ return self._base.extract(carrier, context, getter)
170
+
171
+ def inject(
172
+ self,
173
+ carrier: CarrierT,
174
+ context: otel_context.Context | None = None,
175
+ setter: Setter[CarrierT] = default_setter, # type: ignore[assignment]
176
+ ) -> None:
177
+ ctx = otel_context.get_current() if context is None else context
178
+ if self._runtime.state(ctx) is not None or suppressed() or self._base is None:
179
+ return
180
+ self._base.inject(carrier, context, setter)
181
+
182
+ @property
183
+ def fields(self) -> set[str]:
184
+ return set(OWNED_HEADERS) | (set() if self._base is None else set(self._base.fields))
185
+
186
+
187
+ def _export_spans(exporter: OTLPSpanExporter, spans: Sequence[ReadableSpan]) -> bool:
188
+ return exporter.export(spans) == SpanExportResult.SUCCESS
189
+
190
+
191
+ def _export_logs(exporter: OTLPLogExporter, records: Sequence[ReadableLogRecord]) -> bool:
192
+ return exporter.export(records) == LogRecordExportResult.SUCCESS
193
+
194
+
195
+ class Processors:
196
+ """Construct before the customer's tracer provider; then ``bind`` that provider once.
197
+
198
+ Registers nothing globally.
199
+ """
200
+
201
+ def __init__(self, options: Options) -> None:
202
+ resolved = resolve(options)
203
+ self._runtime = runtime = Runtime(resolved)
204
+ headers = {"Authorization": f"Bearer {options.collector.secret}"}
205
+ timeout = resolved.queue.export_timeout
206
+ span_session, log_session = requests.Session(), requests.Session()
207
+ span_exporter = OTLPSpanExporter(
208
+ endpoint=f"{resolved.endpoint}/v1/traces", headers=headers, timeout=timeout, session=span_session
209
+ )
210
+ log_exporter = OTLPLogExporter(
211
+ endpoint=f"{resolved.endpoint}/v1/logs", headers=headers, timeout=timeout, session=log_session
212
+ )
213
+ # A forked child must not write to a connection the parent still uses:
214
+ # it drops the pooled connections it inherited and opens its own.
215
+ # Weak references: a shut-down SDK's sessions are not kept alive by the hook.
216
+ if hasattr(os, "register_at_fork"):
217
+ sessions = (weakref.ref(span_session), weakref.ref(log_session))
218
+
219
+ def drop_inherited_connections() -> None:
220
+ for reference in sessions:
221
+ session = reference()
222
+ if session is not None:
223
+ session.close()
224
+
225
+ os.register_at_fork(after_in_child=drop_inherited_connections)
226
+ self.spans: ExportQueue[ReadableSpan] = ExportQueue(
227
+ "traces",
228
+ resolved.queue,
229
+ lambda batch: _export_spans(span_exporter, batch),
230
+ lambda code, count: runtime.report(code, "traces", count),
231
+ span_exporter.shutdown,
232
+ )
233
+ self.logs: ExportQueue[ReadableLogRecord] = ExportQueue(
234
+ "logs",
235
+ resolved.queue,
236
+ lambda batch: _export_logs(log_exporter, batch),
237
+ lambda code, count: runtime.report(code, "logs", count),
238
+ log_exporter.shutdown,
239
+ )
240
+ self.span_processor: SpanProcessor = _SpanProcessor(runtime, self.spans)
241
+ self.log_processor: LogRecordProcessor = _LogProcessor(runtime, self.logs)
242
+ self.propagator: TextMapPropagator = _Propagator(runtime, options.propagator)
243
+ self._bound = False
244
+
245
+ def sampler(self, base: Sampler) -> Sampler:
246
+ """Wrap the customer's sampler so managed spans it drops stay record-only."""
247
+ return _Sampler(self._runtime, base)
248
+
249
+ def bind(self, provider: trace.TracerProvider) -> Client:
250
+ """The client that records with ``provider``. Call once."""
251
+ if self._bound:
252
+ raise ConfigurationError("These processors are already bound to a tracer provider.")
253
+ self._bound = True
254
+ return Client(self._runtime, provider, self.spans, self.logs)
255
+
256
+
257
+ def create_processors(options: Options) -> Processors:
258
+ """Agentrail's sampler wrapper, span and log processors and propagator for an existing setup."""
259
+ return Processors(options)