Skip to content

Commit 091ba36

Browse files
Prometheus Metrics (#1447)
* Add metrics server endpoint * Setup metrics subscriber * `MetricsSubscriber` as context manager * Fix lint issues * `--enable-metrics` flag which setup Metrics subscriber, collector and web endpoint * Use file storage based mechanism to share internal metrics with prometheus exporter endpoint * Lint fixes * Move `_setup_metrics_directory` within subscriber which only run once * Use global `metrics_lock` via flags * Remove top-level imports for prometheus_client * Add `requirements-metrics.txt` * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix typo in makefile * Fix typo * fix type, lint, flake issues * Remove event queue prop * Fix typo * Give any role to `proxy.http.server.metrics.get_collector` * rtype * `emit_request_complete` for web servers * Fix doc issues * Refactor * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rename metrics to start with proxypy_work_ * Startup `MetricsEventSubscriber` as part of proxy --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 71f3c65 commit 091ba36

14 files changed

+375
-27
lines changed

.pre-commit-config.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ repos:
163163
- cryptography==36.0.2; python_version <= '3.6'
164164
- types-setuptools == 57.4.2
165165
- pyyaml==5.3.1
166+
# From requirements-metrics.txt
167+
- prometheus_client==0.20.0
166168
args:
167169
# FIXME: get rid of missing imports ignore
168170
- --ignore-missing-imports

.readthedocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@ python:
3737
path: .
3838
- requirements: requirements-tunnel.txt
3939
- requirements: docs/requirements.txt
40+
- requirements: requirements-metrics.txt
4041

4142
...

Makefile

+2-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ lib-dep:
104104
pip install \
105105
-r requirements-testing.txt \
106106
-r requirements-release.txt \
107-
-r requirements-tunnel.txt && \
107+
-r requirements-tunnel.txt \
108+
-r requirements-metrics.txt && \
108109
pip install "setuptools>=42"
109110

110111
lib-pre-commit:

docs/requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.10
2+
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
55
# pip-compile --allow-unsafe --generate-hashes --output-file=docs/requirements.txt --strip-extras docs/requirements.in

proxy/common/constants.py

+4
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ def _env_threadless_compliant() -> bool:
137137
DEFAULT_OPEN_FILE_LIMIT = 1024
138138
DEFAULT_PAC_FILE = None
139139
DEFAULT_PAC_FILE_URL_PATH = b'/'
140+
DEFAULT_ENABLE_METRICS = False
141+
DEFAULT_METRICS_URL_PATH = b"/metrics"
140142
DEFAULT_PID_FILE = None
141143
DEFAULT_PORT_FILE = None
142144
DEFAULT_PLUGINS: List[Any] = []
@@ -172,6 +174,7 @@ def _env_threadless_compliant() -> bool:
172174
)
173175
DEFAULT_CACHE_REQUESTS = False
174176
DEFAULT_CACHE_BY_CONTENT_TYPE = False
177+
DEFAULT_METRICS_DIRECTORY_PATH = os.path.join(DEFAULT_DATA_DIRECTORY_PATH, "metrics")
175178

176179
# Cor plugins enabled by default or via flags
177180
DEFAULT_ABC_PLUGINS = [
@@ -190,6 +193,7 @@ def _env_threadless_compliant() -> bool:
190193
PLUGIN_DEVTOOLS_PROTOCOL = 'proxy.http.inspector.devtools.DevtoolsProtocolPlugin'
191194
PLUGIN_INSPECT_TRAFFIC = 'proxy.http.inspector.inspect_traffic.InspectTrafficPlugin'
192195
PLUGIN_WEBSOCKET_TRANSPORT = 'proxy.http.websocket.transport.WebSocketTransport'
196+
PLUGIN_METRICS = "proxy.http.server.MetricsWebServerPlugin"
193197

194198
PY2_DEPRECATION_MESSAGE = '''DEPRECATION: proxy.py no longer supports Python 2.7. Kindly upgrade to Python 3+. '
195199
'If for some reasons you cannot upgrade, use'

proxy/common/flag.py

+21-6
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,13 @@
2424
from .plugins import Plugins
2525
from .version import __version__
2626
from .constants import (
27-
COMMA, PLUGIN_PAC_FILE, PLUGIN_DASHBOARD, PLUGIN_HTTP_PROXY,
28-
PLUGIN_PROXY_AUTH, PLUGIN_WEB_SERVER, DEFAULT_NUM_WORKERS,
29-
PLUGIN_REVERSE_PROXY, DEFAULT_NUM_ACCEPTORS, PLUGIN_INSPECT_TRAFFIC,
30-
DEFAULT_DISABLE_HEADERS, PY2_DEPRECATION_MESSAGE, DEFAULT_DEVTOOLS_WS_PATH,
31-
PLUGIN_DEVTOOLS_PROTOCOL, PLUGIN_WEBSOCKET_TRANSPORT,
32-
DEFAULT_DATA_DIRECTORY_PATH, DEFAULT_MIN_COMPRESSION_LENGTH,
27+
COMMA, PLUGIN_METRICS, PLUGIN_PAC_FILE, PLUGIN_DASHBOARD,
28+
PLUGIN_HTTP_PROXY, PLUGIN_PROXY_AUTH, PLUGIN_WEB_SERVER,
29+
DEFAULT_NUM_WORKERS, PLUGIN_REVERSE_PROXY, DEFAULT_NUM_ACCEPTORS,
30+
PLUGIN_INSPECT_TRAFFIC, DEFAULT_DISABLE_HEADERS, PY2_DEPRECATION_MESSAGE,
31+
DEFAULT_DEVTOOLS_WS_PATH, PLUGIN_DEVTOOLS_PROTOCOL,
32+
PLUGIN_WEBSOCKET_TRANSPORT, DEFAULT_DATA_DIRECTORY_PATH,
33+
DEFAULT_MIN_COMPRESSION_LENGTH,
3334
)
3435

3536

@@ -182,6 +183,13 @@ def initialize(
182183
args.enable_events,
183184
),
184185
)
186+
args.enable_metrics = cast(
187+
bool,
188+
opts.get(
189+
'enable_metrics',
190+
args.enable_metrics,
191+
),
192+
)
185193

186194
# Load default plugins along with user provided --plugins
187195
default_plugins = [
@@ -195,6 +203,9 @@ def initialize(
195203
default_plugins + auth_plugins + requested_plugins,
196204
)
197205

206+
if bytes_(PLUGIN_METRICS) in default_plugins:
207+
args.metrics_lock = multiprocessing.Lock()
208+
198209
# https://github.com/python/mypy/issues/5865
199210
#
200211
# def option(t: object, key: str, default: Any) -> Any:
@@ -422,6 +433,10 @@ def get_default_plugins(
422433
default_plugins.append(PLUGIN_INSPECT_TRAFFIC)
423434
args.enable_events = True
424435
args.enable_devtools = True
436+
if hasattr(args, 'enable_metrics') and args.enable_metrics:
437+
default_plugins.append(PLUGIN_WEB_SERVER)
438+
default_plugins.append(PLUGIN_METRICS)
439+
args.enable_events = True
425440
if hasattr(args, 'enable_devtools') and args.enable_devtools:
426441
default_plugins.append(PLUGIN_DEVTOOLS_PROTOCOL)
427442
default_plugins.append(PLUGIN_WEB_SERVER)

proxy/core/event/metrics.py

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
proxy.py
4+
~~~~~~~~
5+
⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on
6+
Network monitoring, controls & Application development, testing, debugging.
7+
8+
:copyright: (c) 2013-present by Abhinav Singh and contributors.
9+
:license: BSD, see LICENSE for more details.
10+
"""
11+
import os
12+
import glob
13+
from typing import Any, Dict
14+
from pathlib import Path
15+
from multiprocessing.synchronize import Lock
16+
17+
from ...core.event import EventQueue, EventSubscriber, eventNames
18+
from ...common.constants import DEFAULT_METRICS_DIRECTORY_PATH
19+
20+
21+
class MetricsStorage:
22+
23+
def __init__(self, lock: Lock) -> None:
24+
self._lock = lock
25+
26+
def get_counter(self, name: str) -> float:
27+
with self._lock:
28+
return self._get_counter(name)
29+
30+
def _get_counter(self, name: str) -> float:
31+
path = os.path.join(DEFAULT_METRICS_DIRECTORY_PATH, f'{name}.counter')
32+
if not os.path.exists(path):
33+
return 0
34+
return float(Path(path).read_text(encoding='utf-8').strip())
35+
36+
def incr_counter(self, name: str, by: float = 1.0) -> None:
37+
with self._lock:
38+
self._incr_counter(name, by)
39+
40+
def _incr_counter(self, name: str, by: float = 1.0) -> None:
41+
current = self._get_counter(name)
42+
path = os.path.join(DEFAULT_METRICS_DIRECTORY_PATH, f'{name}.counter')
43+
Path(path).write_text(str(current + by), encoding='utf-8')
44+
45+
def get_gauge(self, name: str) -> float:
46+
with self._lock:
47+
return self._get_gauge(name)
48+
49+
def _get_gauge(self, name: str) -> float:
50+
path = os.path.join(DEFAULT_METRICS_DIRECTORY_PATH, f'{name}.gauge')
51+
if not os.path.exists(path):
52+
return 0
53+
return float(Path(path).read_text(encoding='utf-8').strip())
54+
55+
def set_gauge(self, name: str, value: float) -> None:
56+
"""Stores a single values."""
57+
with self._lock:
58+
self._set_gauge(name, value)
59+
60+
def _set_gauge(self, name: str, value: float) -> None:
61+
path = os.path.join(DEFAULT_METRICS_DIRECTORY_PATH, f'{name}.gauge')
62+
with open(path, 'w', encoding='utf-8') as g:
63+
g.write(str(value))
64+
65+
66+
class MetricsEventSubscriber:
67+
68+
def __init__(self, event_queue: EventQueue, metrics_lock: Lock) -> None:
69+
"""Aggregates metric events pushed by proxy.py core and plugins.
70+
71+
1) Metrics are stored and managed by multiprocessing safe MetricsStorage
72+
2) Collection must be done via MetricsWebServerPlugin endpoint
73+
"""
74+
self.storage = MetricsStorage(metrics_lock)
75+
self.subscriber = EventSubscriber(
76+
event_queue,
77+
callback=lambda event: MetricsEventSubscriber.callback(self.storage, event),
78+
)
79+
80+
def setup(self) -> None:
81+
self._setup_metrics_directory()
82+
self.subscriber.setup()
83+
84+
def shutdown(self) -> None:
85+
self.subscriber.shutdown()
86+
87+
def __enter__(self) -> 'MetricsEventSubscriber':
88+
self.setup()
89+
return self
90+
91+
def __exit__(self, *args: Any) -> None:
92+
self.shutdown()
93+
94+
@staticmethod
95+
def callback(storage: MetricsStorage, event: Dict[str, Any]) -> None:
96+
if event['event_name'] == eventNames.WORK_STARTED:
97+
storage.incr_counter('work_started')
98+
elif event['event_name'] == eventNames.REQUEST_COMPLETE:
99+
storage.incr_counter('request_complete')
100+
elif event['event_name'] == eventNames.WORK_FINISHED:
101+
storage.incr_counter('work_finished')
102+
else:
103+
print('Unhandled', event)
104+
105+
def _setup_metrics_directory(self) -> None:
106+
os.makedirs(DEFAULT_METRICS_DIRECTORY_PATH, exist_ok=True)
107+
patterns = ['*.counter', '*.gauge']
108+
for pattern in patterns:
109+
files = glob.glob(os.path.join(DEFAULT_METRICS_DIRECTORY_PATH, pattern))
110+
for file_path in files:
111+
try:
112+
os.remove(file_path)
113+
except OSError as e:
114+
print(f'Error deleting file {file_path}: {e}')

proxy/http/server/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111
from .web import HttpWebServerPlugin
1212
from .plugin import ReverseProxyBasePlugin, HttpWebServerBasePlugin
13+
from .metrics import MetricsWebServerPlugin
1314
from .protocols import httpProtocolTypes
1415
from .pac_plugin import HttpWebServerPacFilePlugin
1516

@@ -20,4 +21,5 @@
2021
'HttpWebServerBasePlugin',
2122
'httpProtocolTypes',
2223
'ReverseProxyBasePlugin',
24+
'MetricsWebServerPlugin',
2325
]

0 commit comments

Comments
 (0)