Skip to content

Commit 04dcffa

Browse files
Health and Ready endpoints (#3788)
feat: Adiciona health check endpoints Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com>
1 parent 4792c78 commit 04dcffa

File tree

8 files changed

+167
-21
lines changed

8 files changed

+167
-21
lines changed

sapl/api/urls.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
21
from django.conf.urls import include, url
32
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, \
43
SpectacularRedocView
54
from rest_framework.authtoken.views import obtain_auth_token
65

76
from sapl.api.deprecated import SessaoPlenariaViewSet
8-
from sapl.api.views import AppVersionView, recria_token,\
9-
SaplApiViewSetConstrutor
7+
from sapl.api.views import recria_token, SaplApiViewSetConstrutor
108

119
from .apps import AppConfig
12-
10+
from .views_health import HealthzView, ReadyzView
1311

1412
app_name = AppConfig.name
1513

@@ -38,7 +36,6 @@
3836
url(r'^api/', include(urlpatterns_api_doc)),
3937
url(r'^api/', include(urlpatterns_router)),
4038

41-
url(r'^api/version', AppVersionView.as_view()),
4239
url(r'^api/auth/token$', obtain_auth_token),
4340
url(r'^api/recriar-token/(?P<pk>\d*)$', recria_token, name="recria_token"),
4441
]

sapl/api/views.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

33
from django.conf import settings
4+
from django.http import HttpResponse, JsonResponse
45
from rest_framework.authtoken.models import Token
56
from rest_framework.decorators import api_view, permission_classes
67
from rest_framework.permissions import IsAuthenticated, IsAdminUser
@@ -21,20 +22,6 @@ def recria_token(request, pk):
2122
return Response({"message": "Token recriado com sucesso!", "token": token.key})
2223

2324

24-
class AppVersionView(APIView):
25-
permission_classes = (IsAuthenticated,)
26-
27-
def get(self, request):
28-
content = {
29-
'name': 'SAPL',
30-
'description': 'Sistema de Apoio ao Processo Legislativo',
31-
'version': settings.SAPL_VERSION,
32-
'user': request.user.username,
33-
'is_authenticated': request.user.is_authenticated,
34-
}
35-
return Response(content)
36-
37-
3825
SaplApiViewSetConstrutor = ApiViewSetConstrutor
3926
SaplApiViewSetConstrutor.import_modules([
4027
'sapl.api.views_audiencia',

sapl/api/views_health.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import logging
2+
import traceback
3+
4+
from django.http import HttpResponse, JsonResponse
5+
from django.utils import timezone
6+
from rest_framework.views import APIView
7+
from rest_framework.response import Response
8+
from rest_framework import status
9+
from django.conf import settings
10+
from sapl.health import check_app, check_db, check_cache
11+
12+
COMMON_HEADERS = {
13+
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
14+
"Pragma": "no-cache",
15+
}
16+
17+
18+
def _format_plain(ok: bool) -> HttpResponse:
19+
return HttpResponse("OK\n" if ok else "UNHEALTHY\n",
20+
status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE,
21+
content_type="text/plain")
22+
23+
24+
class HealthzView(APIView):
25+
authentication_classes = []
26+
permission_classes = []
27+
28+
logger = logging.getLogger(__name__)
29+
30+
def get(self, request):
31+
try:
32+
ok, msg, ms = check_app()
33+
payload = {
34+
"status": "OK" if ok else "UNHEALTHY",
35+
"checks": {"app": {"ok": ok, "latency_ms": round(ms, 1), "error": msg}},
36+
"version": settings.SAPL_VERSION,
37+
"time": timezone.now().isoformat(),
38+
}
39+
if request.query_params.get("fmt") == "txt":
40+
return _format_plain(ok)
41+
return Response(payload,
42+
status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE,
43+
headers=COMMON_HEADERS)
44+
except Exception as e:
45+
self.logger.error(traceback.format_exc())
46+
return "An internal error has occurred!"
47+
48+
49+
50+
class ReadyzView(APIView):
51+
authentication_classes = []
52+
permission_classes = []
53+
54+
logger = logging.getLogger(__name__)
55+
56+
def get(self, request):
57+
try:
58+
checks = {
59+
"app": check_app(),
60+
"db": check_db(),
61+
"cache": check_cache(),
62+
}
63+
payload_checks = {
64+
name: {"ok": r[0], "latency_ms": round(r[2], 1), "error": r[1]}
65+
for name, r in checks.items()
66+
}
67+
ok = all(r[0] for r in checks.values())
68+
payload = {
69+
"status": "ok" if ok else "unhealthy",
70+
"checks": payload_checks,
71+
"version": settings.SAPL_VERSION,
72+
"time": timezone.now().isoformat(),
73+
}
74+
if request.query_params.get("fmt") == "txt":
75+
return _format_plain(ok)
76+
return Response(payload,
77+
status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE,
78+
headers=COMMON_HEADERS)
79+
except Exception as e:
80+
self.logger.error(traceback.format_exc())
81+
return "An internal error has occurred!"
82+
83+
84+
class AppzVersionView(APIView):
85+
authentication_classes = []
86+
permission_classes = []
87+
88+
logger = logging.getLogger(__name__)
89+
90+
def get(self, request):
91+
try:
92+
payload = {
93+
'name': 'SAPL',
94+
'description': 'Sistema de Apoio ao Processo Legislativo',
95+
'version': settings.SAPL_VERSION,
96+
}
97+
if request.query_params.get("fmt") == "txt":
98+
return HttpResponse(f"{payload['version']} {payload['name']}",
99+
status=status.HTTP_200_OK,
100+
content_type="text/plain")
101+
return Response(payload,
102+
status=status.HTTP_200_OK,
103+
headers=COMMON_HEADERS)
104+
except Exception as e:
105+
self.logger.error(traceback.format_exc())
106+
return "An internal error has occurred!"

sapl/endpoint_restriction_middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
'ff00::/8'
1919
]
2020

21-
RESTRICTED_ENDPOINTS = ['/metrics']
21+
RESTRICTED_ENDPOINTS = ['/metrics', '/health', '/ready', '/version']
2222

2323

2424
class EndpointRestrictionMiddleware:

sapl/health.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# core/health.py
2+
import logging
3+
import time
4+
from typing import Tuple, Optional
5+
from django.db import connection
6+
from django.core.cache import cache
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def check_app() -> Tuple[bool, Optional[str], float]:
12+
t0 = time.monotonic()
13+
return True, None, (time.monotonic() - t0) * 1000
14+
15+
16+
def check_db() -> Tuple[bool, Optional[str], float]:
17+
t0 = time.monotonic()
18+
try:
19+
with connection.cursor() as cur:
20+
cur.execute("SELECT 1")
21+
cur.fetchone()
22+
return True, None, (time.monotonic() - t0) * 1000
23+
except Exception as e:
24+
logging.error(e)
25+
return False, "An internal error has occurred!", (time.monotonic() - t0) * 1000
26+
27+
28+
def check_cache() -> Tuple[bool, Optional[str], float]:
29+
t0 = time.monotonic()
30+
try:
31+
cache.set("_hc", "1", 5)
32+
ok = cache.get("_hc") == "1"
33+
return ok, None if ok else "Cache get/set failed", (time.monotonic() - t0) * 1000
34+
except Exception as e:
35+
logging.error(e)
36+
return False, "An internal error has occurred!", (time.monotonic() - t0) * 1000

sapl/metrics.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from prometheus_client import CollectorRegistry, Gauge, generate_latest, CONTENT_TYPE_LATEST
2+
3+
4+
def health_registry(check_results: dict) -> bytes:
5+
"""
6+
check_results: {"app": True/False, "db": True/False, ...}
7+
"""
8+
reg = CollectorRegistry()
9+
g = Gauge("app_health", "1 if healthy, 0 if unhealthy", ["component"], registry=reg)
10+
for comp, ok in check_results.items():
11+
g.labels(component=comp).set(1 if ok else 0)
12+
return generate_latest(reg)

sapl/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@
149149
'django.middleware.clickjacking.XFrameOptionsMiddleware',
150150
'django.middleware.security.SecurityMiddleware',
151151
'whitenoise.middleware.WhiteNoiseMiddleware',
152-
'django_prometheus.middleware.PrometheusAfterMiddleware',
153152
'waffle.middleware.WaffleMiddleware',
154153
'sapl.middleware.CheckWeakPasswordMiddleware',
154+
'django_prometheus.middleware.PrometheusAfterMiddleware',
155155
]
156156
if DEBUG:
157157
INSTALLED_APPS += ('debug_toolbar',)

sapl/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
import sapl.relatorios.urls
3737
import sapl.sessao.urls
3838

39+
from sapl.api.views_health import AppzVersionView, HealthzView, ReadyzView
40+
3941
urlpatterns = []
4042

4143
urlpatterns += [
@@ -69,6 +71,12 @@
6971
path("robots.txt", TemplateView.as_view(
7072
template_name="robots.txt", content_type="text/plain")),
7173

74+
# Health and Readiness
75+
url(r'^version/$', AppzVersionView.as_view(), name="version"),
76+
url(r"^health/$", HealthzView.as_view(), name="health"),
77+
url(r"^ready/$", ReadyzView.as_view(), name="ready"),
78+
79+
# Monitoring
7280
path(r'', include('django_prometheus.urls')),
7381

7482
]

0 commit comments

Comments
 (0)