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