Skip to content

Commit 8811495

Browse files
committed
added metrics executor
1 parent 4e0ab18 commit 8811495

19 files changed

Lines changed: 309 additions & 98 deletions

File tree

extapi/_meta.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import importlib.util
22

33
has_open_telemetry = importlib.util.find_spec("opentelemetry") is not None
4+
has_prometheus = importlib.util.find_spec("prometheus_client") is not None

extapi/http/abc.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Any, Generic, Protocol, Self, TypeVar, runtime_checkable
33

44
from multidict import CIMultiDict
5+
from yarl import URL
56

67
from .types import RequestData, Response, StrOrURL
78

@@ -48,7 +49,7 @@ async def get(
4849
return await self.execute(
4950
RequestData(
5051
method="GET",
51-
url=url,
52+
url=URL(url) if isinstance(url, str) else url,
5253
params=params,
5354
json=json,
5455
data=data,
@@ -72,7 +73,7 @@ async def post(
7273
return await self.execute(
7374
RequestData(
7475
method="POST",
75-
url=url,
76+
url=URL(url) if isinstance(url, str) else url,
7677
params=params,
7778
json=json,
7879
data=data,
@@ -96,7 +97,7 @@ async def delete(
9697
return await self.execute(
9798
RequestData(
9899
method="DELETE",
99-
url=url,
100+
url=URL(url) if isinstance(url, str) else url,
100101
params=params,
101102
json=json,
102103
data=data,
@@ -120,7 +121,7 @@ async def put(
120121
return await self.execute(
121122
RequestData(
122123
method="PUT",
123-
url=url,
124+
url=URL(url) if isinstance(url, str) else url,
124125
params=params,
125126
json=json,
126127
data=data,
@@ -144,7 +145,7 @@ async def patch(
144145
return await self.execute(
145146
RequestData(
146147
method="PATCH",
147-
url=url,
148+
url=URL(url) if isinstance(url, str) else url,
148149
params=params,
149150
json=json,
150151
data=data,

extapi/http/addons/log.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,10 @@ def __init__(self, *, log_params: bool = True):
1616
self._log_params = log_params
1717

1818
def _get_url(self, request: RequestData) -> URL:
19-
url_ = URL(request.url) if not isinstance(request.url, URL) else request.url
20-
2119
if self._log_params and request.params:
22-
return url_.with_query(request.params)
20+
return request.url.with_query(request.params)
2321

24-
return url_
22+
return request.url
2523

2624
async def before_request(self, request: RequestData) -> None:
2725
url = self._get_url(request)

extapi/http/backends/aiohttp.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,28 @@ async def json(
3737
return await self._original.json(encoding=encoding, loads=loads)
3838

3939

40+
_aiohttp_extra_kwargs = [
41+
"cookies",
42+
"skip_auto_headers",
43+
"auth",
44+
"allow_redirects",
45+
"max_redirects",
46+
"compress",
47+
"chunked",
48+
"expect100",
49+
"raise_for_status",
50+
"read_until_eof",
51+
"proxy",
52+
"proxy_auth",
53+
"server_hostname",
54+
"trace_request_ctx",
55+
"read_bufsize",
56+
"auto_decompress",
57+
"max_line_size",
58+
"max_field_size",
59+
]
60+
61+
4062
class AiohttpExecutor(AbstractExecutor[aiohttp.ClientResponse]):
4163
__slots__ = (
4264
"_ssl",
@@ -49,15 +71,27 @@ def __init__(
4971
):
5072
super().__init__()
5173
self._ssl = ssl
52-
self._session = aiohttp.ClientSession(*args, **kwargs)
74+
self._session = self._make_session(*args, **kwargs)
5375
self._default_timeout = default_timeout
5476

77+
def _make_session(self, *args, **kwargs) -> aiohttp.ClientSession:
78+
return aiohttp.ClientSession(*args, **kwargs)
79+
5580
async def close(self):
5681
await self._session.close()
5782

5883
async def execute(self, request: RequestData) -> Response[aiohttp.ClientResponse]:
5984
timeout = request.timeout or self._default_timeout
6085

86+
# aiohttp-specific kwargs
87+
# we need to pull them individually because
88+
# we may have our own custom kwargs
89+
aiohttp_kwargs = {
90+
key: request.kwargs[key]
91+
for key in _aiohttp_extra_kwargs
92+
if key in request.kwargs
93+
}
94+
6195
response = await self._session.request(
6296
method=request.method,
6397
url=request.url,
@@ -67,7 +101,7 @@ async def execute(self, request: RequestData) -> Response[aiohttp.ClientResponse
67101
headers=request.headers,
68102
timeout=timeout, # type: ignore[arg-type]
69103
ssl=self._ssl,
70-
**request.kwargs,
104+
**aiohttp_kwargs,
71105
)
72106

73107
return Response[aiohttp.ClientResponse](

extapi/http/backends/httpx.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import httpx
44
from multidict import CIMultiDict
5-
from yarl import URL
65

76
from extapi.http.abc import AbstractExecutor
87
from extapi.http.types import (
@@ -28,6 +27,16 @@ async def read(self) -> bytes:
2827
return await self._original.aread()
2928

3029

30+
_httpx_extra_kwargs = [
31+
"content",
32+
"files",
33+
"cookies",
34+
"auth",
35+
"follow_redirects",
36+
"extensions",
37+
]
38+
39+
3140
class HttpxExecutor(AbstractExecutor[httpx.Response], metaclass=abc.ABCMeta):
3241
__slots__ = (
3342
"_client",
@@ -46,26 +55,35 @@ def __init__(
4655
verify = kwargs.pop("verify", None)
4756
if verify is None:
4857
verify = check_ssl
49-
self._client = httpx.AsyncClient(
58+
self._client = self._make_client(
5059
verify=verify, follow_redirects=follow_redirects, **kwargs
5160
)
5261
self._default_timeout = default_timeout
5362

63+
def _make_client(self, *args, **kwargs) -> httpx.AsyncClient:
64+
return httpx.AsyncClient(*args, **kwargs)
65+
5466
async def close(self):
5567
await self._client.aclose()
5668

5769
async def execute(self, request: RequestData) -> Response[httpx.Response]:
5870
timeout = request.timeout or self._default_timeout
59-
url = request.url
60-
61-
if isinstance(url, URL):
62-
url = str(url)
71+
url = str(request.url)
6372

6473
if request.headers is None:
6574
httpx_headers = []
6675
else:
6776
httpx_headers = [(k, str(v)) for k, v in request.headers.items()]
6877

78+
# httpx-specific kwargs
79+
# we need to pull them individually because
80+
# we may have our own custom kwargs
81+
httpx_kwargs = {
82+
key: request.kwargs[key]
83+
for key in _httpx_extra_kwargs
84+
if key in request.kwargs
85+
}
86+
6987
response = await self._client.stream(
7088
method=request.method,
7189
url=url,
@@ -74,11 +92,11 @@ async def execute(self, request: RequestData) -> Response[httpx.Response]:
7492
data=request.data,
7593
headers=httpx_headers,
7694
timeout=timeout,
77-
**request.kwargs,
95+
**httpx_kwargs,
7896
).__aenter__()
7997

8098
return Response[httpx.Response](
81-
url=url,
99+
url=request.url,
82100
status=response.status_code,
83101
headers=CIMultiDict(response.headers),
84102
backend_response=HttpxResponseWrap(response),

extapi/http/executors/metrics.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import warnings
2+
3+
from extapi._meta import has_prometheus
4+
5+
if not has_prometheus:
6+
raise ImportError( # pragma: no cover
7+
"opentelemetry is not installed - run `pip install prometheus_client`"
8+
)
9+
10+
import time
11+
from typing import Generic, TypeVar
12+
13+
from extapi.http.abc import AbstractExecutor
14+
from extapi.http.types import RequestData, Response
15+
16+
from ..metrics.container import MetricsContainer
17+
from .wrapped import WrappedExecutor
18+
19+
T = TypeVar("T", covariant=True)
20+
21+
22+
class PrometheusMetricsExecutor(WrappedExecutor[T], Generic[T]):
23+
def __init__(
24+
self, executor: AbstractExecutor[T], *, metrics_container: MetricsContainer
25+
):
26+
super().__init__(executor)
27+
self._metrics_container = metrics_container
28+
29+
async def execute(self, request: RequestData) -> Response[T]:
30+
path_template = request.kwargs.pop("path_template", None)
31+
if path_template is None:
32+
warnings.warn(
33+
"It is highly recommended to pass `path_template` "
34+
"argument to the executor in order to to not blow the l"
35+
"abel cardinality when path is customized",
36+
UserWarning,
37+
stacklevel=1,
38+
)
39+
40+
path = path_template or request.url.path
41+
42+
method = request.method.upper()
43+
started_at = time.monotonic()
44+
try:
45+
resp = await super().execute(request)
46+
except Exception as e:
47+
label_values = (
48+
request.url.scheme,
49+
request.url.host,
50+
request.url.port,
51+
method,
52+
path,
53+
e.__class__.__name__,
54+
)
55+
self._metrics_container.requests_error.labels(*label_values).inc()
56+
self._metrics_container.requests_duration_error.labels(
57+
*label_values
58+
).observe(time.monotonic() - started_at)
59+
raise
60+
else:
61+
label_values = (
62+
request.url.scheme,
63+
request.url.host,
64+
request.url.port,
65+
method,
66+
path,
67+
str(resp.status),
68+
)
69+
self._metrics_container.requests.labels(*label_values).inc()
70+
self._metrics_container.requests_duration.labels(*label_values).observe(
71+
time.monotonic() - started_at
72+
)
73+
return resp

extapi/http/executors/trace.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
from multidict import CIMultiDict
2-
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
3-
41
from extapi._meta import has_open_telemetry
52

63
if not has_open_telemetry:
74
raise ImportError( # pragma: no cover
85
"opentelemetry is not installed - run `pip install opentelemetry-api opentelemetry-sdk`"
96
)
107

11-
128
from typing import Generic, TypeVar
139

10+
from multidict import CIMultiDict
1411
from opentelemetry import trace
1512
from opentelemetry.semconv.trace import SpanAttributes
16-
from yarl import URL
13+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
1714

1815
from extapi.http.abc import AbstractExecutor
1916
from extapi.http.types import RequestData, Response
@@ -60,9 +57,6 @@ async def execute(self, request: RequestData) -> Response[T]:
6057

6158
span.set_attribute(SpanAttributes.HTTP_REQUEST_METHOD, request.method)
6259

63-
if not isinstance(request.url, URL):
64-
request.url = URL(request.url)
65-
6660
if request.url.host is not None:
6761
span.set_attribute(SpanAttributes.SERVER_ADDRESS, request.url.host)
6862

extapi/http/metrics/__init__.py

Whitespace-only changes.

extapi/http/metrics/container.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from prometheus_client import REGISTRY, CollectorRegistry, Counter, Histogram
2+
3+
from .helpers import DEFAULT_BUCKETS, with_prefix
4+
5+
6+
class MetricsContainer:
7+
def __init__(
8+
self,
9+
*,
10+
metrics_prefix: str,
11+
metrics_registry: CollectorRegistry = REGISTRY,
12+
):
13+
self.requests = Counter(
14+
name=with_prefix("external_service_request", prefix=metrics_prefix),
15+
documentation="Count of external requests",
16+
labelnames=[
17+
"scheme",
18+
"domain",
19+
"port",
20+
"method",
21+
"path",
22+
"status",
23+
],
24+
registry=metrics_registry,
25+
)
26+
27+
self.requests_duration = Histogram(
28+
name=with_prefix(
29+
"external_service_request_duration_seconds", prefix=metrics_prefix
30+
),
31+
documentation="External request duration in seconds",
32+
labelnames=[
33+
"scheme",
34+
"domain",
35+
"port",
36+
"method",
37+
"path",
38+
"status",
39+
],
40+
buckets=DEFAULT_BUCKETS,
41+
registry=metrics_registry,
42+
)
43+
44+
self.requests_error = Counter(
45+
name=with_prefix("external_service_errored_request", prefix=metrics_prefix),
46+
documentation="Count of errored external requests",
47+
labelnames=["scheme", "domain", "port", "method", "path", "error_type"],
48+
registry=metrics_registry,
49+
)
50+
51+
self.requests_duration_error = Histogram(
52+
name=with_prefix(
53+
"external_service_errored_request_duration_seconds",
54+
prefix=metrics_prefix,
55+
),
56+
documentation="Errored external request duration in seconds",
57+
labelnames=["scheme", "domain", "port", "method", "path", "error_type"],
58+
buckets=DEFAULT_BUCKETS,
59+
registry=metrics_registry,
60+
)

0 commit comments

Comments
 (0)