Skip to content

Commit 21ee6bf

Browse files
committed
feat(metrics): record requests and httpx durations as a histogram
Add the `network` option to the `metrics` client config. When set, the SDK wraps `requests.Session.send` and, when httpx is installed, `httpx.Client.send` and `httpx.AsyncClient.send`, and records `http.client.request.duration` with `method`, `host`, templated `path` and `status_class` attributes. `name` accepts a string or a function, `attributes` accepts a function. The wrappers only observe: they call the original with the same arguments, return its result or re-raise its error, and never let a recording failure reach the caller. Each redirect hop that `requests` sends is folded into the outer request. The SDK's own sessions are marked so PostHog's uploads are not recorded, and the wrappers are removed on `shutdown()`. This mirrors `metrics.network` in posthog-js. Generated-By: PostHog Desktop Task-Id: 7cbd4d4f-b9f1-4246-ba2a-dbe574f78b08
1 parent 1b98d48 commit 21ee6bf

9 files changed

Lines changed: 614 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
Add the `network` option to the `metrics` client config. When set, the SDK records the duration of every HTTP request the application makes with `requests` or `httpx` as the `http.client.request.duration` histogram, with `method`, `host`, templated `path` and `status_class` attributes. `name` and `attributes` functions customise what is recorded. The SDK's own requests are skipped, and the wrappers are removed on `shutdown()`.

posthog/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,15 @@ def get_tags() -> Dict[str, Any]:
339339
metrics: Config dict for the ``client.metrics`` API (``service_name``,
340340
``service_version``, ``environment``, ``flush_interval``, ...). Applied
341341
when ``setup()`` builds the global client, or on a later ``setup()``
342-
call if the metrics API hasn't been used yet.
342+
call if the metrics API hasn't been used yet. Set ``network`` to
343+
``True`` to record the duration of every HTTP request the application
344+
makes with ``requests`` or ``httpx`` as the
345+
``http.client.request.duration`` histogram, with ``method``, ``host``,
346+
templated ``path`` and ``status_class`` attributes. Pass a dict with
347+
``name`` (a string, or a function of the request that returns the name
348+
or ``None`` to skip it) and ``attributes`` (a function of the request
349+
and response whose result is merged over the defaults) to customise it.
350+
The SDK's own requests are not recorded.
343351
enable_exception_autocapture: Automatically capture uncaught exceptions.
344352
log_captured_exceptions: Also log exceptions captured by error tracking.
345353
project_root: Root path used to determine in-app exception stack frames.
@@ -1298,7 +1306,7 @@ def setup() -> Client:
12981306
# module-attr assignment (e.g. a Django ready() hook running after something
12991307
# already forced setup()) still applies until the metrics API is first used.
13001308
if default_client._metrics is None:
1301-
default_client._metrics_config = metrics
1309+
default_client._configure_metrics(metrics)
13021310

13031311
return default_client
13041312

posthog/_async_request.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .capture_compression import CaptureCompression
1414
from .capture_v1 import _parse_retry_after, _send_v1_batch
15+
from .network_metrics import _mark_internal
1516
from .request import (
1617
APIError,
1718
DatetimeSerializer,
@@ -38,7 +39,9 @@ def _require_httpx():
3839
def _build_client(host: Optional[str] = None):
3940
httpx_module = _require_httpx()
4041
base_url = remove_trailing_slash(normalize_host(host))
41-
return httpx_module.AsyncClient(base_url=base_url, follow_redirects=False)
42+
return _mark_internal(
43+
httpx_module.AsyncClient(base_url=base_url, follow_redirects=False)
44+
)
4245

4346

4447
def _serialize_v0_body(

posthog/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,7 @@ def __init__(
10771077
)
10781078

10791079
self._warn_if_duplicate_async_client()
1080+
self._configure_metrics(metrics)
10801081

10811082
def _set_library_identity(self, library_id: str, library_version: str) -> None:
10821083
"""Override the SDK identity stamped on events and outbound requests."""
@@ -2466,6 +2467,12 @@ def metrics(self) -> PostHogMetrics:
24662467
self._metrics = PostHogMetrics(self, None)
24672468
return self._metrics
24682469

2470+
def _configure_metrics(self, metrics: Optional[dict]) -> None:
2471+
self._metrics_config = metrics
2472+
if isinstance(metrics, dict) and metrics.get("network"):
2473+
# Building the metrics API installs the network request wrappers.
2474+
_ = self.metrics
2475+
24692476
def flush(self, timeout_seconds: Optional[float] = 10) -> None:
24702477
"""
24712478
Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.
@@ -2681,6 +2688,11 @@ def _shutdown_once(self, errors: list[Exception]) -> None:
26812688
self._flush_or_discard_queues(errors)
26822689

26832690
if self._metrics is not None:
2691+
self._run_lifecycle_cleanup(
2692+
"Failed to stop network metrics on shutdown",
2693+
self._metrics._stop_network_metrics,
2694+
errors,
2695+
)
26842696
self._run_lifecycle_cleanup(
26852697
"Failed to flush metrics on shutdown", self._metrics.flush, errors
26862698
)

posthog/metrics_capture.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import requests
3535

36+
from posthog.network_metrics import _NetworkMetrics
3637
from posthog.request import _get_session
3738
from posthog.utils import remove_trailing_slash
3839
from posthog.version import VERSION
@@ -285,6 +286,11 @@ def __init__(self, client, config: Optional[dict] = None):
285286
self._type_by_name: dict = {}
286287
self._type_collision_warned: set = set()
287288

289+
network = config.get("network")
290+
self._network: Optional[_NetworkMetrics] = (
291+
_NetworkMetrics(self, network) if network else None
292+
)
293+
288294
def count(
289295
self,
290296
name: str,
@@ -329,6 +335,11 @@ def reset(self) -> None:
329335
self._type_by_name = {}
330336
self._type_collision_warned = set()
331337

338+
def _stop_network_metrics(self) -> None:
339+
if self._network is not None:
340+
self._network.stop()
341+
self._network = None
342+
332343
def _guarded_capture(
333344
self,
334345
metric_type: str,

posthog/network_metrics.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Automatic duration metrics for the HTTP requests an application makes.
2+
3+
Enabled with the ``metrics={"network": ...}`` client option. Wraps
4+
``requests.Session.send`` and, when httpx is installed, ``httpx.Client.send``
5+
and ``httpx.AsyncClient.send``. The wrappers only observe: they call the
6+
original with the same arguments, return its result or re-raise its error,
7+
and never let a recording failure reach the caller. The SDK marks its own
8+
sessions with ``_mark_internal`` so PostHog's uploads are not recorded.
9+
"""
10+
11+
import contextvars
12+
import functools
13+
import logging
14+
import re
15+
import time
16+
from typing import Any, Callable, List, Optional, Tuple
17+
from urllib.parse import urlsplit
18+
19+
import requests
20+
21+
try:
22+
import httpx
23+
except ImportError: # pragma: no cover
24+
httpx = None
25+
26+
log = logging.getLogger("posthog")
27+
28+
DEFAULT_METRIC_NAME = "http.client.request.duration"
29+
30+
_INTERNAL_MARKER = "_posthog_internal"
31+
32+
# ``requests`` sends each redirect hop through ``Session.send`` again. Only the
33+
# outermost send in a thread or task is recorded, so a redirected request counts once.
34+
_in_flight: contextvars.ContextVar[bool] = contextvars.ContextVar(
35+
"posthog_network_metrics_in_flight", default=False
36+
)
37+
38+
_ALL_DIGITS = re.compile(r"^\d+$")
39+
_HEX_WITH_A_DIGIT = re.compile(r"^[0-9a-f-]*\d[0-9a-f-]*$", re.IGNORECASE)
40+
41+
42+
def _mark_internal(http_client):
43+
"""Marks a ``requests.Session`` or httpx client as the SDK's own.
44+
45+
Its requests are never recorded as network metrics.
46+
"""
47+
setattr(http_client, _INTERNAL_MARKER, True)
48+
return http_client
49+
50+
51+
def _is_internal(http_client) -> bool:
52+
return getattr(http_client, _INTERNAL_MARKER, False) is True
53+
54+
55+
def _is_id_like(segment: str) -> bool:
56+
return bool(_ALL_DIGITS.match(segment)) or (
57+
len(segment) >= 8 and bool(_HEX_WITH_A_DIGIT.match(segment))
58+
)
59+
60+
61+
def _template_path(path: str) -> str:
62+
"""Replaces each all-digit or uuid-like path segment with ``:id``."""
63+
return "/".join(
64+
":id" if _is_id_like(segment) else segment for segment in path.split("/")
65+
)
66+
67+
68+
def _status_class(status: Optional[int]) -> str:
69+
return "{}xx".format(status // 100) if status else "missing"
70+
71+
72+
def _parse_config(config: Any) -> Tuple[Any, Optional[Callable]]:
73+
if config is True:
74+
config = {}
75+
if not isinstance(config, dict):
76+
log.warning(
77+
"Ignoring metrics network config: expected True or a dict, got %s",
78+
type(config).__name__,
79+
)
80+
config = {}
81+
name = config.get("name", DEFAULT_METRIC_NAME)
82+
if not (isinstance(name, str) or callable(name)):
83+
log.warning("Ignoring metrics network name: expected a string or a callable")
84+
name = DEFAULT_METRIC_NAME
85+
attributes = config.get("attributes")
86+
if attributes is not None and not callable(attributes):
87+
log.warning("Ignoring metrics network attributes: expected a callable")
88+
attributes = None
89+
return name, attributes
90+
91+
92+
def _patch(target, attribute: str, make_wrapper: Callable) -> Callable[[], None]:
93+
original = getattr(target, attribute)
94+
wrapper = make_wrapper(original)
95+
setattr(target, attribute, wrapper)
96+
97+
def restore() -> None:
98+
# Another wrapper layered on top keeps ours in place as a pass-through.
99+
if getattr(target, attribute) is wrapper:
100+
setattr(target, attribute, original)
101+
102+
return restore
103+
104+
105+
class _NetworkMetrics:
106+
"""Installs the request wrappers for one metrics client; ``stop()`` removes them."""
107+
108+
def __init__(self, metrics, config: Any):
109+
self._metrics = metrics
110+
self._name, self._attributes = _parse_config(config)
111+
self._active = True
112+
self._record_error_warned = False
113+
self._restores: List[Callable[[], None]] = [
114+
_patch(requests.Session, "send", self._wrap_sync)
115+
]
116+
if httpx is not None:
117+
self._restores.append(_patch(httpx.Client, "send", self._wrap_sync))
118+
self._restores.append(_patch(httpx.AsyncClient, "send", self._wrap_async))
119+
120+
def stop(self) -> None:
121+
self._active = False
122+
for restore in self._restores:
123+
restore()
124+
125+
def _observes(self, http_client) -> bool:
126+
return self._active and not _in_flight.get() and not _is_internal(http_client)
127+
128+
def _wrap_sync(self, original: Callable) -> Callable:
129+
@functools.wraps(original)
130+
def send(http_client, request, *args, **kwargs):
131+
if not self._observes(http_client):
132+
return original(http_client, request, *args, **kwargs)
133+
token = _in_flight.set(True)
134+
start = time.perf_counter()
135+
try:
136+
response = original(http_client, request, *args, **kwargs)
137+
except Exception:
138+
self._record(request, None, start)
139+
raise
140+
finally:
141+
_in_flight.reset(token)
142+
self._record(request, response, start)
143+
return response
144+
145+
return send
146+
147+
def _wrap_async(self, original: Callable) -> Callable:
148+
@functools.wraps(original)
149+
async def send(http_client, request, *args, **kwargs):
150+
if not self._observes(http_client):
151+
return await original(http_client, request, *args, **kwargs)
152+
token = _in_flight.set(True)
153+
start = time.perf_counter()
154+
try:
155+
response = await original(http_client, request, *args, **kwargs)
156+
except Exception:
157+
self._record(request, None, start)
158+
raise
159+
finally:
160+
_in_flight.reset(token)
161+
self._record(request, response, start)
162+
return response
163+
164+
return send
165+
166+
def _record(self, request, response, start: float) -> None:
167+
try:
168+
duration_ms = (time.perf_counter() - start) * 1000
169+
url = str(request.url)
170+
parts = urlsplit(url)
171+
if parts.scheme not in ("http", "https"):
172+
return
173+
observed = {"url": url, "method": str(request.method).upper()}
174+
name = self._name(observed) if callable(self._name) else self._name
175+
if not name:
176+
return
177+
status = getattr(response, "status_code", None)
178+
attributes = {
179+
"method": observed["method"],
180+
"host": parts.hostname or "",
181+
"path": _template_path(parts.path),
182+
"status_class": _status_class(status),
183+
}
184+
if self._attributes is not None:
185+
extra = self._attributes(
186+
observed, {"status": status, "duration_ms": duration_ms}
187+
)
188+
attributes.update(extra or {})
189+
self._metrics.histogram(name, duration_ms, unit="ms", attributes=attributes)
190+
except Exception as e:
191+
if not self._record_error_warned:
192+
self._record_error_warned = True
193+
log.warning("Failed to record network metric: %s", e)

posthog/request.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from urllib3.util.retry import Retry
1717

1818
from posthog._logging import _configure_posthog_logging
19+
from posthog.network_metrics import _mark_internal
1920
from posthog.utils import remove_trailing_slash
2021
from posthog.version import VERSION
2122

@@ -84,7 +85,7 @@ def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.S
8485
),
8586
socket_options=socket_options,
8687
)
87-
session = requests.Session()
88+
session = _mark_internal(requests.Session())
8889
session.mount("https://", adapter)
8990
return session
9091

@@ -101,7 +102,7 @@ def _build_flags_session(
101102
max_retries=Retry(total=0, connect=0, read=0, status=0),
102103
socket_options=socket_options,
103104
)
104-
session = requests.Session()
105+
session = _mark_internal(requests.Session())
105106
session.mount("https://", adapter)
106107
return session
107108

0 commit comments

Comments
 (0)