Skip to content

Commit 653b450

Browse files
committed
Add native OpenTelemetry client telemetry
1 parent 70fac53 commit 653b450

6 files changed

Lines changed: 774 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dev = [
3535
# Packaging
3636
"build==1.3.0",
3737
"twine==6.1.0",
38+
"logfire>=4.37.0",
3839
]
3940
docs = ["zensical>=0.0.41", "mkdocstrings[python]>=0.27"]
4041
bench = [

src/httpx2/httpx2/_client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
request_context,
2929
)
3030
from ._models import Cookies, Headers, Request, Response
31+
from ._opentelemetry import get_opentelemetry
3132
from ._sse import EventSource
3233
from ._status_codes import codes
3334
from ._transports.base import AsyncBaseTransport, BaseTransport
@@ -1015,7 +1016,13 @@ def _send_single_request(self, request: Request) -> Response:
10151016
raise RuntimeError("Attempted to send an async request with a sync Client instance.")
10161017

10171018
with request_context(request=request):
1018-
response = transport.handle_request(request)
1019+
otel = get_opentelemetry()
1020+
if otel is None or not otel.is_enabled(request):
1021+
response = transport.handle_request(request)
1022+
else:
1023+
with otel.trace_request(request) as trace:
1024+
response = transport.handle_request(request)
1025+
trace.set_response(response)
10191026

10201027
assert isinstance(response.stream, SyncByteStream)
10211028

@@ -1761,7 +1768,13 @@ async def _send_single_request(self, request: Request) -> Response:
17611768
raise RuntimeError("Attempted to send a sync request with an AsyncClient instance.")
17621769

17631770
with request_context(request=request):
1764-
response = await transport.handle_async_request(request)
1771+
otel = get_opentelemetry()
1772+
if otel is None or not otel.is_enabled(request):
1773+
response = await transport.handle_async_request(request)
1774+
else:
1775+
with otel.trace_request(request) as trace:
1776+
response = await transport.handle_async_request(request)
1777+
trace.set_response(response)
17651778

17661779
assert isinstance(response.stream, AsyncByteStream)
17671780
response.request = request
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import os
5+
import re
6+
import time
7+
import typing
8+
from functools import cache
9+
10+
from .__version__ import __version__
11+
from ._models import Headers, Request, Response
12+
13+
if typing.TYPE_CHECKING: # pragma: no cover
14+
from collections.abc import Generator
15+
16+
KNOWN_HTTP_METHODS = {
17+
"CONNECT",
18+
"DELETE",
19+
"GET",
20+
"HEAD",
21+
"OPTIONS",
22+
"PATCH",
23+
"POST",
24+
"PUT",
25+
"QUERY",
26+
"TRACE",
27+
}
28+
29+
SEMCONV_SCHEMA_URL = "https://opentelemetry.io/schemas/1.42.0"
30+
INSTRUMENTATION_NAME = "httpx2"
31+
CLIENT_REQUEST_DURATION = "http.client.request.duration"
32+
SENSITIVE_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie"}
33+
HTTP_CLIENT_REQUEST_DURATION_BUCKETS = (
34+
0.005,
35+
0.01,
36+
0.025,
37+
0.05,
38+
0.075,
39+
0.1,
40+
0.25,
41+
0.5,
42+
0.75,
43+
1,
44+
2.5,
45+
5,
46+
7.5,
47+
10,
48+
)
49+
50+
51+
def get_opentelemetry() -> OpenTelemetry | None:
52+
return _get_opentelemetry()
53+
54+
55+
@cache
56+
def _get_opentelemetry() -> OpenTelemetry | None:
57+
try:
58+
from opentelemetry import context, metrics, propagate, trace
59+
from opentelemetry.trace import SpanKind, Status, StatusCode
60+
except ImportError:
61+
return None
62+
63+
is_http_instrumentation_enabled: typing.Callable[[], bool] | None
64+
try:
65+
from opentelemetry.instrumentation.utils import is_http_instrumentation_enabled
66+
except ImportError:
67+
is_http_instrumentation_enabled = None
68+
69+
return OpenTelemetry(
70+
context=context,
71+
metrics=metrics,
72+
propagate=propagate,
73+
trace=trace,
74+
span_kind=SpanKind,
75+
status=Status,
76+
status_code=StatusCode,
77+
is_http_instrumentation_enabled=is_http_instrumentation_enabled,
78+
)
79+
80+
81+
class OpenTelemetry:
82+
def __init__(
83+
self,
84+
*,
85+
context: typing.Any,
86+
metrics: typing.Any,
87+
propagate: typing.Any,
88+
trace: typing.Any,
89+
span_kind: typing.Any,
90+
status: typing.Any,
91+
status_code: typing.Any,
92+
is_http_instrumentation_enabled: typing.Callable[[], bool] | None,
93+
) -> None:
94+
self._context = context
95+
self._propagate = propagate
96+
self._span_kind = span_kind
97+
self._status = status
98+
self._status_code = status_code
99+
self._is_http_instrumentation_enabled = is_http_instrumentation_enabled
100+
self._suppress_instrumentation_key = getattr(context, "_SUPPRESS_INSTRUMENTATION_KEY", None)
101+
self._suppress_http_instrumentation_key = getattr(context, "_SUPPRESS_HTTP_INSTRUMENTATION_KEY", None)
102+
self._tracer = _get_tracer(trace)
103+
meter = _get_meter(metrics)
104+
self._duration_histogram = _create_duration_histogram(meter)
105+
106+
def is_enabled(self, request: Request) -> bool:
107+
if not _is_http_url(request):
108+
return False
109+
110+
if self._is_http_instrumentation_enabled is not None:
111+
return self._is_http_instrumentation_enabled()
112+
113+
if self._suppress_instrumentation_key is not None and self._context.get_value(
114+
self._suppress_instrumentation_key
115+
):
116+
return False
117+
118+
if self._suppress_http_instrumentation_key is not None and self._context.get_value(
119+
self._suppress_http_instrumentation_key
120+
):
121+
return False
122+
123+
return True
124+
125+
@contextlib.contextmanager
126+
def trace_request(self, request: Request) -> Generator[RequestTrace]:
127+
span_attributes = _request_attributes(request)
128+
metric_attributes = _request_metric_attributes(request)
129+
span_name = _span_name(request.method)
130+
span_cm = self._tracer.start_as_current_span(
131+
span_name,
132+
kind=self._span_kind.CLIENT,
133+
attributes=span_attributes,
134+
)
135+
span = span_cm.__enter__()
136+
trace = RequestTrace(
137+
span=span,
138+
duration_histogram=self._duration_histogram,
139+
metric_attributes=metric_attributes,
140+
status=self._status,
141+
status_code=self._status_code,
142+
)
143+
start = time.perf_counter()
144+
try:
145+
self._propagate.inject(request.headers)
146+
yield trace
147+
except BaseException as exc:
148+
trace.set_exception(exc)
149+
raise
150+
finally:
151+
trace.record_duration(time.perf_counter() - start)
152+
span_cm.__exit__(*trace.exc_info)
153+
154+
155+
class RequestTrace:
156+
def __init__(
157+
self,
158+
*,
159+
span: typing.Any,
160+
duration_histogram: typing.Any,
161+
metric_attributes: dict[str, typing.Any],
162+
status: typing.Any,
163+
status_code: typing.Any,
164+
) -> None:
165+
self._span = span
166+
self._duration_histogram = duration_histogram
167+
self._metric_attributes = metric_attributes
168+
self._status = status
169+
self._status_code = status_code
170+
self.exc_info: tuple[type[BaseException] | None, BaseException | None, typing.Any] = (None, None, None)
171+
172+
def set_response(self, response: Response) -> None:
173+
response_attributes = _response_attributes(response)
174+
self._metric_attributes.update(_response_metric_attributes(response))
175+
_set_attributes(self._span, response_attributes)
176+
177+
if _is_error_status(response.status_code):
178+
self._set_error(str(response.status_code))
179+
180+
def set_exception(self, exc: BaseException) -> None:
181+
self.exc_info = (type(exc), exc, exc.__traceback__)
182+
self._set_error(f"{type(exc).__module__}.{type(exc).__qualname__}")
183+
184+
def record_duration(self, duration: float) -> None:
185+
self._duration_histogram.record(max(duration, 0), attributes=self._metric_attributes)
186+
187+
def _set_error(self, error_type: str) -> None:
188+
self._metric_attributes["error.type"] = error_type
189+
if self._span.is_recording():
190+
self._span.set_attribute("error.type", error_type)
191+
self._span.set_status(self._status(self._status_code.ERROR))
192+
193+
194+
def _request_attributes(request: Request) -> dict[str, typing.Any]:
195+
attributes = _request_metric_attributes(request)
196+
attributes["url.full"] = _redact_url(request)
197+
198+
captured_headers = _captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_REQUEST")
199+
if captured_headers:
200+
attributes.update(_header_attributes("http.request.header", request.headers, captured_headers))
201+
202+
return attributes
203+
204+
205+
def _request_metric_attributes(request: Request) -> dict[str, typing.Any]:
206+
method = _known_method(request.method)
207+
attributes: dict[str, typing.Any] = {
208+
"http.request.method": method,
209+
}
210+
211+
if method == "_OTHER":
212+
attributes["http.request.method_original"] = request.method
213+
214+
if request.url.host:
215+
attributes["server.address"] = request.url.host
216+
217+
port = request.url.port or {"http": 80, "https": 443}.get(request.url.scheme)
218+
if port is not None:
219+
attributes["server.port"] = port
220+
221+
return attributes
222+
223+
224+
def _response_attributes(response: Response) -> dict[str, typing.Any]:
225+
attributes = _response_metric_attributes(response)
226+
227+
captured_headers = _captured_headers("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_CLIENT_RESPONSE")
228+
if captured_headers:
229+
attributes.update(_header_attributes("http.response.header", response.headers, captured_headers))
230+
231+
return attributes
232+
233+
234+
def _response_metric_attributes(response: Response) -> dict[str, typing.Any]:
235+
attributes: dict[str, typing.Any] = {"http.response.status_code": response.status_code}
236+
237+
if response.http_version:
238+
attributes["network.protocol.version"] = response.http_version.removeprefix("HTTP/")
239+
240+
return attributes
241+
242+
243+
def _is_http_url(request: Request) -> bool:
244+
return request.url.scheme in {"http", "https"}
245+
246+
247+
def _span_name(method: str) -> str:
248+
method = _known_method(method)
249+
return "HTTP" if method == "_OTHER" else method
250+
251+
252+
def _known_method(method: str) -> str:
253+
return method if method in KNOWN_HTTP_METHODS else "_OTHER"
254+
255+
256+
def _redact_url(request: Request) -> str:
257+
if request.url.userinfo:
258+
return str(request.url.copy_with(username="REDACTED", password="REDACTED"))
259+
return str(request.url)
260+
261+
262+
def _is_error_status(status_code: int) -> bool:
263+
return status_code >= 400
264+
265+
266+
def _set_attributes(span: typing.Any, attributes: dict[str, typing.Any]) -> None:
267+
if not span.is_recording():
268+
return
269+
270+
for name, value in attributes.items():
271+
span.set_attribute(name, value)
272+
273+
274+
def _captured_headers(name: str) -> list[re.Pattern[str]]:
275+
value = os.environ.get(name, "")
276+
return [re.compile(pattern.strip(), re.IGNORECASE) for pattern in value.split(",") if pattern.strip()]
277+
278+
279+
def _header_attributes(
280+
prefix: str,
281+
headers: Headers,
282+
captured_headers: list[re.Pattern[str]],
283+
) -> dict[str, list[str]]:
284+
sensitive_headers = _sensitive_headers()
285+
attributes: dict[str, list[str]] = {}
286+
for key in headers.keys():
287+
if not any(pattern.fullmatch(key) for pattern in captured_headers):
288+
continue
289+
attribute = f"{prefix}.{key.lower().replace('-', '_')}"
290+
values = headers.get_list(key, split_commas=True)
291+
attributes[attribute] = [
292+
"[REDACTED]" if _is_sensitive_header(key, sensitive_headers) else value for value in values
293+
]
294+
return attributes
295+
296+
297+
def _sensitive_headers() -> list[re.Pattern[str]]:
298+
value = os.environ.get("OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS", "")
299+
names = [*SENSITIVE_HEADERS, *[item.strip() for item in value.split(",") if item.strip()]]
300+
return [re.compile(name, re.IGNORECASE) for name in names]
301+
302+
303+
def _is_sensitive_header(key: str, sensitive_headers: list[re.Pattern[str]]) -> bool:
304+
return any(pattern.fullmatch(key) for pattern in sensitive_headers)
305+
306+
307+
def _get_tracer(trace: typing.Any) -> typing.Any:
308+
try:
309+
return trace.get_tracer(
310+
INSTRUMENTATION_NAME,
311+
instrumenting_library_version=__version__,
312+
schema_url=SEMCONV_SCHEMA_URL,
313+
)
314+
except TypeError:
315+
return trace.get_tracer(INSTRUMENTATION_NAME, __version__)
316+
317+
318+
def _get_meter(metrics: typing.Any) -> typing.Any:
319+
try:
320+
return metrics.get_meter(
321+
INSTRUMENTATION_NAME,
322+
instrumenting_library_version=__version__,
323+
schema_url=SEMCONV_SCHEMA_URL,
324+
)
325+
except TypeError:
326+
return metrics.get_meter(INSTRUMENTATION_NAME, __version__)
327+
328+
329+
def _create_duration_histogram(meter: typing.Any) -> typing.Any:
330+
try:
331+
return meter.create_histogram(
332+
name=CLIENT_REQUEST_DURATION,
333+
unit="s",
334+
description="Duration of HTTP client requests.",
335+
explicit_bucket_boundaries_advisory=HTTP_CLIENT_REQUEST_DURATION_BUCKETS,
336+
)
337+
except TypeError:
338+
return meter.create_histogram(
339+
name=CLIENT_REQUEST_DURATION,
340+
unit="s",
341+
description="Duration of HTTP client requests.",
342+
)

src/httpx2/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ cli = [
6464
http2 = [
6565
"h2>=3,<5",
6666
]
67+
opentelemetry = [
68+
"opentelemetry-api>=1.42.0",
69+
]
6770
socks = [
6871
"socksio==1.*",
6972
]

0 commit comments

Comments
 (0)