forked from numerique-gouv/impress
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨(backend) Adding /prometheus logging endpoint numerique-gouv#455
implements various metrics for users, documents and the actual django application Signed-off-by: lindenb1 <[email protected]>
- Loading branch information
Showing
10 changed files
with
301 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
from prometheus_client.core import GaugeMetricFamily | ||
from django.utils.timezone import now | ||
from django.db.models import Count, Min, Max, Q, F | ||
from datetime import timedelta | ||
from core import models | ||
from django.conf import settings | ||
|
||
|
||
class CustomMetricsExporter: | ||
""" | ||
Custom Prometheus metrics collector for user and document statistics. | ||
""" | ||
|
||
def collect(self): | ||
namespace = getattr(settings, "PROMETHEUS_METRIC_NAMESPACE", "") | ||
|
||
def prefixed_metric_name(name): | ||
return f"{namespace}_{name}" if namespace else name | ||
|
||
now_time = now() | ||
today_start_utc = now_time.replace(hour=0, minute=0, second=0, microsecond=0) | ||
one_week_ago = today_start_utc - timedelta(days=7) | ||
one_month_ago = today_start_utc - timedelta(days=30) | ||
|
||
user_count = models.User.objects.count() | ||
active_users_today = models.User.objects.filter( | ||
Q(documentaccess__updated_at__gte=today_start_utc) | | ||
Q(link_traces__created_at__gte=today_start_utc) | | ||
Q(last_login__gte=today_start_utc) | ||
).distinct().count() | ||
active_users_7_days = models.User.objects.filter( | ||
Q(documentaccess__updated_at__gte=one_week_ago) | | ||
Q(link_traces__created_at__gte=one_week_ago) | | ||
Q(last_login__gte=one_week_ago) | ||
).distinct().count() | ||
active_users_30_days = models.User.objects.filter( | ||
Q(documentaccess__updated_at__gte=one_month_ago) | | ||
Q(link_traces__created_at__gte=one_month_ago) | | ||
Q(last_login__gte=one_month_ago) | ||
).distinct().count() | ||
|
||
total_documents = models.Document.objects.count() | ||
shared_docs_count = models.Document.objects.annotate( | ||
access_count=Count("accesses") | ||
).filter(access_count__gt=1).count() | ||
active_docs_today = models.Document.objects.filter( | ||
updated_at__gte=today_start_utc, | ||
updated_at__lt=today_start_utc + timedelta(days=1), | ||
).count() | ||
active_docs_last_7_days = models.Document.objects.filter( | ||
updated_at__gte=one_week_ago | ||
).count() | ||
active_docs_last_30_days = models.Document.objects.filter( | ||
updated_at__gte=one_month_ago | ||
).count() | ||
|
||
oldest_doc_date = models.Document.objects.aggregate( | ||
oldest=Min("created_at") | ||
)["oldest"] | ||
newest_doc_date = models.Document.objects.aggregate( | ||
newest=Max("created_at") | ||
)["newest"] | ||
|
||
user_doc_counts = models.DocumentAccess.objects.values("user_id").annotate( | ||
doc_count=Count("document_id"), | ||
admin_email=F("user__admin_email") | ||
) | ||
|
||
metrics = [] | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("total_users"), "Total number of users", value=user_count)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("active_users_today"), "Number of active users today", value=active_users_today)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("active_users_7_days"), "Number of active users in the last 7 days", value=active_users_7_days)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("active_users_30_days"), "Number of active users in the last 30 days", value=active_users_30_days)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("total_documents"), "Total number of documents", value=total_documents)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("shared_documents"), "Number of shared documents", value=shared_docs_count)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("active_documents_today"), "Number of active documents today", value=active_docs_today)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("active_documents_7_days"), "Number of active documents in the last 7 days", value=active_docs_last_7_days)) | ||
metrics.append(GaugeMetricFamily(prefixed_metric_name("active_documents_30_days"), "Number of active documents in the last 30 days", value=active_docs_last_30_days)) | ||
|
||
if oldest_doc_date: | ||
metrics.append(GaugeMetricFamily( | ||
prefixed_metric_name("oldest_document_date"), "Timestamp of the oldest document creation date", | ||
value=oldest_doc_date.timestamp() | ||
)) | ||
if newest_doc_date: | ||
metrics.append(GaugeMetricFamily( | ||
prefixed_metric_name("newest_document_date"), "Timestamp of the newest document creation date", | ||
value=newest_doc_date.timestamp() | ||
)) | ||
|
||
user_distribution_metric = GaugeMetricFamily( | ||
prefixed_metric_name("user_document_distribution"), "Document counts per user", labels=["user_email"] | ||
) | ||
for user in user_doc_counts: | ||
if user["admin_email"]: # Validate email existence | ||
user_distribution_metric.add_metric([user["admin_email"]], user["doc_count"]) | ||
metrics.append(user_distribution_metric) | ||
|
||
for metric in metrics: | ||
yield metric |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import uuid | ||
import requests | ||
from django.http import JsonResponse, HttpResponseServerError, HttpResponse | ||
from django.db import connections | ||
from django.db.utils import OperationalError | ||
from django.core.cache import cache | ||
from django.core.files.storage import default_storage | ||
from django.core.files.base import ContentFile | ||
|
||
from impress import settings | ||
|
||
|
||
def liveness_check(request): | ||
""" | ||
Liveness probe endpoint. | ||
Returns HTTP 200 if the application is alive and running. | ||
""" | ||
try: | ||
return JsonResponse({"status": "OK"}, status=200) | ||
except Exception as e: | ||
return HttpResponseServerError({"status": "error", "details": str(e)}) | ||
|
||
|
||
def readiness_check(request): | ||
""" | ||
Readiness probe endpoint. | ||
Checks database, cache, media storage, and OIDC configuration. | ||
Returns HTTP 200 with JSON status "OK" if all checks pass, | ||
or HTTP 500 with JSON status "Error" and an error message. | ||
""" | ||
|
||
def check_database(): | ||
"""Check database connectivity.""" | ||
try: | ||
db_conn = connections['default'] | ||
db_conn.cursor() | ||
except OperationalError as e: | ||
raise Exception(f"Database check failed: {e}") | ||
|
||
def check_cache(): | ||
"""Check cache connectivity.""" | ||
test_key = "readiness-probe" | ||
test_value = "ready" | ||
cache.set(test_key, test_value, timeout=5) | ||
if cache.get(test_key) != test_value: | ||
raise Exception("Cache check failed: Value mismatch or cache unavailable") | ||
|
||
def check_media_storage(): | ||
"""Check S3 storage connectivity.""" | ||
test_file_name = f"readiness-check-{uuid.uuid4()}.txt" | ||
test_content = ContentFile(b"readiness check") | ||
try: | ||
# Attempt to save the test file | ||
default_storage.save(test_file_name, test_content) | ||
# Attempt to delete the test file | ||
default_storage.delete(test_file_name) | ||
except Exception as e: | ||
# Raise an exception if any error occurs during save or delete | ||
raise Exception(f"Media storage check failed: {e}") | ||
|
||
def check_oidc(): | ||
"""Check OIDC configuration and connectivity.""" | ||
required_endpoints = [ | ||
("OIDC_OP_JWKS_ENDPOINT", settings.OIDC_OP_JWKS_ENDPOINT), | ||
("OIDC_OP_TOKEN_ENDPOINT", settings.OIDC_OP_TOKEN_ENDPOINT), | ||
("OIDC_OP_USER_ENDPOINT", settings.OIDC_OP_USER_ENDPOINT), | ||
] | ||
|
||
missing_endpoints = [name for name, url in required_endpoints if not url] | ||
if missing_endpoints: | ||
raise Exception(f"Missing OIDC configuration for: {', '.join(missing_endpoints)}") | ||
|
||
for name, url in required_endpoints: | ||
try: | ||
requests.get(url, timeout=5) # Just ensure the endpoint responds no matter the http status code | ||
except requests.RequestException as e: | ||
raise Exception(f"Failed to reach {name} ({url}): {e}") | ||
|
||
try: | ||
# Run all checks | ||
check_database() | ||
check_cache() | ||
check_media_storage() | ||
check_oidc() | ||
|
||
# If all checks pass | ||
return JsonResponse({"status": "OK"}, status=200) | ||
|
||
except Exception as e: | ||
# Return error response | ||
return JsonResponse({"status": "Error", "message": str(e)}, status=500) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import os | ||
from ipaddress import ip_network, ip_address | ||
from django.http import HttpResponseForbidden | ||
|
||
|
||
def monitoring_cidr_protected_view(view): | ||
""" | ||
Decorator to protect a view with a CIDR filter. | ||
CIDR ranges are fetched from the environment variable `MONITORING_ALLOWED_CIDR_RANGES`. | ||
If set to '*', all clients are allowed. If not set or empty, access is denied. | ||
""" | ||
# Fetch allowed CIDR ranges from the environment variable | ||
cidr_env = os.environ.get("MONITORING_ALLOWED_CIDR_RANGES", "").strip() | ||
|
||
# Handle the special case for allowing all clients | ||
allow_all = cidr_env == "*" | ||
|
||
# Validate and parse CIDR ranges if not allowing all | ||
try: | ||
allowed_cidr_ranges = [ | ||
ip_network(cidr.strip().strip('"').strip("'")) | ||
for cidr in cidr_env.split(",") | ||
if cidr.strip() and cidr != "*" | ||
] | ||
except ValueError as e: | ||
raise ValueError(f"Invalid CIDR range in MONITORING_ALLOWED_CIDR_RANGES: {e}") | ||
|
||
def wrapped_view(request, *args, **kwargs): | ||
# Get the client's IP address from the request | ||
client_ip = request.META.get("REMOTE_ADDR") | ||
|
||
# Allow all clients if explicitly configured | ||
if allow_all: | ||
return view(request, *args, **kwargs) | ||
|
||
# If no CIDR ranges are configured, deny access | ||
if not allowed_cidr_ranges: | ||
return HttpResponseForbidden("Access denied: No allowed CIDR ranges configured.") | ||
|
||
# Check if the client's IP is in the allowed CIDR ranges | ||
if not any(ip_address(client_ip) in cidr for cidr in allowed_cidr_ranges): | ||
return HttpResponseForbidden("Access denied: Your IP is not allowed.") | ||
|
||
# Proceed to the original view | ||
return view(request, *args, **kwargs) | ||
|
||
return wrapped_view |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters