Skip to content

Commit cad9107

Browse files
committed
feat(monitoring): add Prometheus metrics endpoint and instrumentation
1 parent d9bd064 commit cad9107

4 files changed

Lines changed: 203 additions & 38 deletions

File tree

analytics/__init__.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
import time
33
import logging
44
import threading
5+
from pyramid.response import Response
56
from pyramid.session import SignedCookieSessionFactory
67
from pyramid.renderers import JSONP
78
from pyramid.config import Configurator
89

910
from analytics import controller
1011
from analytics import charts_config
12+
from analytics import monitoring
1113

1214
from analytics.views_website import cache_region as views_website_cache_region
1315
from analytics.views_ajax import cache_region as views_ajax_cache_region
@@ -17,7 +19,39 @@
1719
logger = logging.getLogger(__name__)
1820

1921

20-
def _start_cache_prewarm(settings):
22+
def _metrics_view(_request):
23+
body, content_type = monitoring.metrics_response()
24+
return Response(body=body, content_type=content_type)
25+
26+
27+
def _request_metrics_tween_factory(handler, registry):
28+
def _tween(request):
29+
route_name = "unknown"
30+
if request.matched_route is not None:
31+
route_name = request.matched_route.name
32+
method = request.method
33+
timer = monitoring.HTTP_REQUEST_DURATION_SECONDS.labels(method=method, route=route_name).time()
34+
monitoring.HTTP_REQUESTS_IN_PROGRESS.labels(method=method, route=route_name).inc()
35+
try:
36+
with timer:
37+
response = handler(request)
38+
status_code = str(getattr(response, "status_code", "200"))
39+
monitoring.HTTP_REQUESTS_TOTAL.labels(
40+
method=method, route=route_name, status_code=status_code
41+
).inc()
42+
return response
43+
except Exception:
44+
monitoring.HTTP_REQUESTS_TOTAL.labels(
45+
method=method, route=route_name, status_code="500"
46+
).inc()
47+
raise
48+
finally:
49+
monitoring.HTTP_REQUESTS_IN_PROGRESS.labels(method=method, route=route_name).dec()
50+
51+
return _tween
52+
53+
54+
def _start_cache_prewarm(settings, usage_api_base_url=None):
2155
if os.environ.get("ENABLE_CACHE_PREWARM", "1").lower() in ("0", "false", "no"):
2256
return
2357

@@ -32,7 +66,7 @@ def _prewarm():
3266
settings.get('articlemeta', None),
3367
settings.get('publicationstats', None),
3468
settings.get('citedby', None),
35-
settings.get('usage', None),
69+
usage_api_base_url,
3670
request=None
3771
)
3872
stats.articlemeta.certified_collections()
@@ -71,13 +105,14 @@ def main(global_config, **settings):
71105
"""
72106
config = Configurator(settings=settings)
73107
config.add_renderer('jsonp', JSONP(param_name='callback', indent=4))
108+
usage_api_base_url = os.environ.get('USAGE_API_BASE_URL', settings.get('usage', None))
74109

75110
def add_stats(request):
76111
return controller.Stats(
77112
settings.get('articlemeta', None),
78113
settings.get('publicationstats', None),
79114
settings.get('citedby', None),
80-
settings.get('usage', None),
115+
usage_api_base_url,
81116
request
82117
)
83118

@@ -89,6 +124,7 @@ def add_chartsconfig(request):
89124
config.add_route('index_web', '/')
90125
config.add_route('faq_web', '/w/faq')
91126
config.add_route('reports', '/w/reports')
127+
config.add_route('metrics', '/metrics')
92128
config.add_route('usage_report_chart', '/ajx/usage/usage_report_chart')
93129
config.add_route('usage_report_yearly_chart', '/ajx/usage/usage_report_yearly_chart')
94130
config.add_route('accesses_web', '/w/accesses')
@@ -138,6 +174,8 @@ def add_chartsconfig(request):
138174
config.add_subscriber('analytics.subscribers.add_localizer',
139175
'pyramid.events.NewRequest')
140176
config.add_translation_dirs('analytics:locale')
177+
config.add_tween('analytics._request_metrics_tween_factory', over='MAIN')
178+
config.add_view(_metrics_view, route_name='metrics', request_method='GET')
141179

142180
# Cache Settings Config
143181
memcached_host = (
@@ -162,6 +200,8 @@ def add_chartsconfig(request):
162200
views_website_cache_region.configure('dogpile.cache.pylibmc', **cache_config)
163201
controller_cache_region.configure('dogpile.cache.pylibmc', **cache_config)
164202
control_manager_cache_region.configure('dogpile.cache.pylibmc', **cache_config)
203+
monitoring.CACHE_BACKEND_INFO.labels(backend='pylibmc').set(1)
204+
monitoring.CACHE_BACKEND_INFO.labels(backend='memory').set(0)
165205
else:
166206
# Fallback to in-process cache when memcached is unavailable.
167207
# This avoids recomputing heavy remote calls on every request.
@@ -170,11 +210,13 @@ def add_chartsconfig(request):
170210
controller_cache_region.configure('dogpile.cache.memory', **cache_config)
171211
control_manager_cache_region.configure('dogpile.cache.memory', **cache_config)
172212
views_ajax_cache_region.configure('dogpile.cache.memory', **cache_config)
213+
monitoring.CACHE_BACKEND_INFO.labels(backend='memory').set(1)
214+
monitoring.CACHE_BACKEND_INFO.labels(backend='pylibmc').set(0)
173215

174216
# Session config
175217
navegation_session_factory = SignedCookieSessionFactory('sses_navegation')
176218
config.set_session_factory(navegation_session_factory)
177-
_start_cache_prewarm(settings)
219+
_start_cache_prewarm(settings, usage_api_base_url=usage_api_base_url)
178220

179221
config.scan()
180222

analytics/monitoring.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import time
2+
3+
from prometheus_client import (
4+
CONTENT_TYPE_LATEST,
5+
Counter,
6+
Gauge,
7+
Histogram,
8+
generate_latest,
9+
)
10+
11+
12+
HTTP_REQUESTS_TOTAL = Counter(
13+
"analytics_http_requests_total",
14+
"Total HTTP requests handled by the app",
15+
["method", "route", "status_code"],
16+
)
17+
18+
HTTP_REQUEST_DURATION_SECONDS = Histogram(
19+
"analytics_http_request_duration_seconds",
20+
"HTTP request latency in seconds",
21+
["method", "route"],
22+
)
23+
24+
HTTP_REQUESTS_IN_PROGRESS = Gauge(
25+
"analytics_http_requests_in_progress",
26+
"HTTP requests currently in progress",
27+
["method", "route"],
28+
)
29+
30+
BACKEND_CALLS_TOTAL = Counter(
31+
"analytics_backend_calls_total",
32+
"Total backend calls by backend and operation",
33+
["backend", "operation", "result"],
34+
)
35+
36+
BACKEND_CALL_DURATION_SECONDS = Histogram(
37+
"analytics_backend_call_duration_seconds",
38+
"Backend call duration in seconds",
39+
["backend", "operation"],
40+
)
41+
42+
BACKEND_INFLIGHT_REJECTED_TOTAL = Counter(
43+
"analytics_backend_inflight_rejected_total",
44+
"Rejected backend calls due to inflight guard saturation",
45+
["backend", "operation"],
46+
)
47+
48+
CIRCUIT_BREAKER_STATE = Gauge(
49+
"analytics_circuit_breaker_state",
50+
"Circuit breaker state (1=open, 0=closed)",
51+
["host"],
52+
)
53+
54+
CACHE_BACKEND_INFO = Gauge(
55+
"analytics_cache_backend_info",
56+
"Configured cache backend (1 for active backend)",
57+
["backend"],
58+
)
59+
60+
61+
def metrics_response():
62+
return generate_latest(), CONTENT_TYPE_LATEST
63+
64+
65+
def observe_backend_call(backend, operation, callable_obj):
66+
started_at = time.monotonic()
67+
try:
68+
result = callable_obj()
69+
BACKEND_CALLS_TOTAL.labels(backend=backend, operation=operation, result="success").inc()
70+
return result
71+
except Exception:
72+
BACKEND_CALLS_TOTAL.labels(backend=backend, operation=operation, result="error").inc()
73+
raise
74+
finally:
75+
BACKEND_CALL_DURATION_SECONDS.labels(backend=backend, operation=operation).observe(
76+
time.monotonic() - started_at
77+
)

analytics/request_utils.py

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from urllib.parse import urlparse
66

77
import requests
8+
from analytics import monitoring
89

910
from requests.exceptions import (
1011
ConnectionError,
@@ -68,6 +69,7 @@ def _breaker_mark_success():
6869
with _breaker_lock:
6970
_breaker_state["failures"] = 0
7071
_breaker_state["open_until"] = 0.0
72+
monitoring.CIRCUIT_BREAKER_STATE.labels(host=CIRCUIT_BREAKER_HOST).set(0)
7173

7274

7375
def _breaker_mark_failure():
@@ -76,6 +78,7 @@ def _breaker_mark_failure():
7678
failures = _breaker_state["failures"]
7779
if failures >= CIRCUIT_BREAKER_FAILURE_THRESHOLD:
7880
_breaker_state["open_until"] = time.time() + CIRCUIT_BREAKER_OPEN_SECONDS
81+
monitoring.CIRCUIT_BREAKER_STATE.labels(host=CIRCUIT_BREAKER_HOST).set(1)
7982
logger.warning(
8083
"Circuit breaker opened for host '%s' for %ss after %s failures",
8184
CIRCUIT_BREAKER_HOST,
@@ -123,40 +126,46 @@ def fetch_data(url, json=True, params=None, timeout=32, verify=True):
123126
RetryableError: If a connection or timeout error occurs (for retry).
124127
NonRetryableError: For schema, URL, or 4xx client errors.
125128
"""
126-
if _is_breaker_target(url) and _breaker_is_open():
127-
logger.warning(
128-
"Circuit breaker open for host '%s', short-circuiting request: %s",
129-
CIRCUIT_BREAKER_HOST,
130-
url,
131-
)
132-
raise NonRetryableError(CircuitOpenError(url))
129+
def _do_fetch():
130+
if _is_breaker_target(url) and _breaker_is_open():
131+
logger.warning(
132+
"Circuit breaker open for host '%s', short-circuiting request: %s",
133+
CIRCUIT_BREAKER_HOST,
134+
url,
135+
)
136+
monitoring.BACKEND_CALLS_TOTAL.labels(
137+
backend="usage_api", operation="fetch_data", result="circuit_open"
138+
).inc()
139+
raise NonRetryableError(CircuitOpenError(url))
140+
141+
try:
142+
logger.info(f"Fetching URL: {url} with params {params}")
143+
response = requests.get(url, params=params, timeout=timeout, verify=verify)
144+
response.raise_for_status()
145+
if _is_breaker_target(url):
146+
_breaker_mark_success()
133147

134-
try:
135-
logger.info(f"Fetching URL: {url} with params {params}")
136-
response = requests.get(url, params=params, timeout=timeout, verify=verify)
137-
response.raise_for_status()
138-
if _is_breaker_target(url):
139-
_breaker_mark_success()
140-
141-
except (ConnectionError, Timeout) as exc:
142-
if _is_breaker_target(url):
143-
_breaker_mark_failure()
144-
logger.error(f"Erro fetching content: {url}. Retrying... Error: {exc}")
145-
raise RetryableError(exc) from exc
146-
147-
except (InvalidSchema, MissingSchema, InvalidURL) as exc:
148-
logger.error(f"Invalid URL or schema: {url}. Error: {exc}")
149-
raise NonRetryableError(exc) from exc
150-
151-
except HTTPError as exc:
152-
status_code = exc.response.status_code
153-
if 400 <= status_code < 500:
154-
logger.error(f"Client error (non-retryable): {url}. Status: {status_code}")
155-
raise NonRetryableError(exc) from exc
156-
elif 500 <= status_code < 600:
148+
except (ConnectionError, Timeout) as exc:
157149
if _is_breaker_target(url):
158150
_breaker_mark_failure()
159-
logger.error(f"Server error: {url}. Retrying... Status: {status_code}")
151+
logger.error(f"Erro fetching content: {url}. Retrying... Error: {exc}")
160152
raise RetryableError(exc) from exc
161153

162-
return response.json() if json else response.content
154+
except (InvalidSchema, MissingSchema, InvalidURL) as exc:
155+
logger.error(f"Invalid URL or schema: {url}. Error: {exc}")
156+
raise NonRetryableError(exc) from exc
157+
158+
except HTTPError as exc:
159+
status_code = exc.response.status_code
160+
if 400 <= status_code < 500:
161+
logger.error(f"Client error (non-retryable): {url}. Status: {status_code}")
162+
raise NonRetryableError(exc) from exc
163+
elif 500 <= status_code < 600:
164+
if _is_breaker_target(url):
165+
_breaker_mark_failure()
166+
logger.error(f"Server error: {url}. Retrying... Status: {status_code}")
167+
raise RetryableError(exc) from exc
168+
169+
return response.json() if json else response.content
170+
171+
return monitoring.observe_backend_call("usage_api", "fetch_data", _do_fetch)

0 commit comments

Comments
 (0)