baltimore-patapsco 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- baltimore_patapsco-0.1.0/PKG-INFO +41 -0
- baltimore_patapsco-0.1.0/README.md +18 -0
- baltimore_patapsco-0.1.0/pyproject.toml +34 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/__init__.py +7 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/api/__init__.py +7 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/api/errors.py +115 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/api/serializers.py +25 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/api/views.py +93 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/apps.py +7 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/contracts.py +32 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/observability/__init__.py +33 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/observability/context.py +50 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/observability/logging.py +142 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/observability/middleware.py +97 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/observability/sentry.py +39 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/py.typed +0 -0
- baltimore_patapsco-0.1.0/src/baltimore/patapsco/urls.py +10 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: baltimore-patapsco
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared Django runtime foundations for Baltimore civic applications
|
|
5
|
+
Author: Mayor's Office of Performance and Innovation
|
|
6
|
+
Requires-Dist: django>=6.0,<7.0
|
|
7
|
+
Requires-Dist: djangorestframework>=3.16,<4.0
|
|
8
|
+
Requires-Dist: drf-spectacular>=0.28,<0.30
|
|
9
|
+
Requires-Dist: django-allauth[socialaccount]>=65.10,<66.0 ; extra == 'auth'
|
|
10
|
+
Requires-Dist: django-auditlog>=3.3,<4.0 ; extra == 'auth'
|
|
11
|
+
Requires-Dist: django-anymail[sendgrid]>=15.0,<16.0 ; extra == 'email'
|
|
12
|
+
Requires-Dist: sentry-sdk[django]>=2.64.0,<3.0 ; extra == 'observability'
|
|
13
|
+
Requires-Dist: dj-database-url>=3.0,<4.0 ; extra == 'runtime'
|
|
14
|
+
Requires-Dist: django-cors-headers>=4.9,<5.0 ; extra == 'runtime'
|
|
15
|
+
Requires-Dist: django-environ>=0.12,<0.14 ; extra == 'runtime'
|
|
16
|
+
Requires-Dist: whitenoise>=6.9,<7.0 ; extra == 'runtime'
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Provides-Extra: auth
|
|
19
|
+
Provides-Extra: email
|
|
20
|
+
Provides-Extra: observability
|
|
21
|
+
Provides-Extra: runtime
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# baltimore-patapsco (Python)
|
|
25
|
+
|
|
26
|
+
Reusable Django runtime primitives for Baltimore civic applications.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
INSTALLED_APPS += ["baltimore.patapsco"]
|
|
30
|
+
MIDDLEWARE += ["baltimore.patapsco.observability.RequestContextMiddleware"]
|
|
31
|
+
|
|
32
|
+
REST_FRAMEWORK = {
|
|
33
|
+
"EXCEPTION_HANDLER": "baltimore.patapsco.api.exception_handler",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
urlpatterns = [path("", include("baltimore.patapsco.urls"))]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use `build_logging_config()` for the shared JSON/pretty log shape. Modules are
|
|
40
|
+
incrementally adoptable; installing the package does not enable auth, email, or
|
|
41
|
+
Sentry by itself.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# baltimore-patapsco (Python)
|
|
2
|
+
|
|
3
|
+
Reusable Django runtime primitives for Baltimore civic applications.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
INSTALLED_APPS += ["baltimore.patapsco"]
|
|
7
|
+
MIDDLEWARE += ["baltimore.patapsco.observability.RequestContextMiddleware"]
|
|
8
|
+
|
|
9
|
+
REST_FRAMEWORK = {
|
|
10
|
+
"EXCEPTION_HANDLER": "baltimore.patapsco.api.exception_handler",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
urlpatterns = [path("", include("baltimore.patapsco.urls"))]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use `build_logging_config()` for the shared JSON/pretty log shape. Modules are
|
|
17
|
+
incrementally adoptable; installing the package does not enable auth, email, or
|
|
18
|
+
Sentry by itself.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "baltimore-patapsco"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared Django runtime foundations for Baltimore civic applications"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
authors = [{ name = "Mayor's Office of Performance and Innovation" }]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"django>=6.0,<7.0",
|
|
10
|
+
"djangorestframework>=3.16,<4.0",
|
|
11
|
+
"drf-spectacular>=0.28,<0.30",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
runtime = [
|
|
16
|
+
"dj-database-url>=3.0,<4.0",
|
|
17
|
+
"django-cors-headers>=4.9,<5.0",
|
|
18
|
+
"django-environ>=0.12,<0.14",
|
|
19
|
+
"whitenoise>=6.9,<7.0",
|
|
20
|
+
]
|
|
21
|
+
observability = ["sentry-sdk[django]>=2.64.0,<3.0"]
|
|
22
|
+
auth = [
|
|
23
|
+
"django-allauth[socialaccount]>=65.10,<66.0",
|
|
24
|
+
"django-auditlog>=3.3,<4.0",
|
|
25
|
+
]
|
|
26
|
+
email = ["django-anymail[sendgrid]>=15.0,<16.0"]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.11.28,<0.15"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
module-name = "baltimore.patapsco"
|
|
34
|
+
module-root = "src"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Mapping, Sequence
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from baltimore.patapsco.contracts import (
|
|
6
|
+
REQUEST_ID_HEADER,
|
|
7
|
+
ErrorBody,
|
|
8
|
+
ErrorEnvelope,
|
|
9
|
+
error_code_for_status,
|
|
10
|
+
)
|
|
11
|
+
from baltimore.patapsco.observability import get_request_id, new_request_id
|
|
12
|
+
from rest_framework.response import Response
|
|
13
|
+
from rest_framework.views import exception_handler as drf_exception_handler
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("baltimore.patapsco.api")
|
|
16
|
+
|
|
17
|
+
_INTERNAL_MESSAGE = "The request could not be completed."
|
|
18
|
+
_VALIDATION_MESSAGE = "Review the submitted values and try again."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_error_payload(
|
|
22
|
+
*,
|
|
23
|
+
code: str,
|
|
24
|
+
message: str,
|
|
25
|
+
request_id: str,
|
|
26
|
+
details: object | None = None,
|
|
27
|
+
) -> ErrorEnvelope:
|
|
28
|
+
error: ErrorBody = {
|
|
29
|
+
"code": code,
|
|
30
|
+
"message": message,
|
|
31
|
+
"request_id": request_id,
|
|
32
|
+
}
|
|
33
|
+
if details is not None:
|
|
34
|
+
error["details"] = details
|
|
35
|
+
return {"error": error}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def api_error_response(
|
|
39
|
+
*,
|
|
40
|
+
status_code: int,
|
|
41
|
+
code: str | None = None,
|
|
42
|
+
message: str,
|
|
43
|
+
request_id: str | None = None,
|
|
44
|
+
details: object | None = None,
|
|
45
|
+
) -> Response:
|
|
46
|
+
resolved_request_id = request_id or get_request_id() or new_request_id()
|
|
47
|
+
response = Response(
|
|
48
|
+
build_error_payload(
|
|
49
|
+
code=code or error_code_for_status(status_code),
|
|
50
|
+
message=message,
|
|
51
|
+
request_id=resolved_request_id,
|
|
52
|
+
details=details,
|
|
53
|
+
),
|
|
54
|
+
status=status_code,
|
|
55
|
+
)
|
|
56
|
+
response[REQUEST_ID_HEADER] = resolved_request_id
|
|
57
|
+
return response
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _request_id_from_context(context: Mapping[str, Any]) -> str:
|
|
61
|
+
request = context.get("request")
|
|
62
|
+
value = getattr(request, "request_id", None)
|
|
63
|
+
return value if isinstance(value, str) and value else get_request_id() or new_request_id()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _public_error_parts(data: object, status_code: int) -> tuple[str, object | None]:
|
|
67
|
+
if status_code >= 500:
|
|
68
|
+
return _INTERNAL_MESSAGE, None
|
|
69
|
+
|
|
70
|
+
if isinstance(data, Mapping):
|
|
71
|
+
detail = data.get("detail")
|
|
72
|
+
if isinstance(detail, str) and detail.strip():
|
|
73
|
+
return detail, None
|
|
74
|
+
return _VALIDATION_MESSAGE, dict(data)
|
|
75
|
+
|
|
76
|
+
if isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)):
|
|
77
|
+
return _VALIDATION_MESSAGE, list(data)
|
|
78
|
+
|
|
79
|
+
if isinstance(data, str) and data.strip():
|
|
80
|
+
return data, None
|
|
81
|
+
return _INTERNAL_MESSAGE, None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def exception_handler(exc: Exception, context: dict[str, Any]) -> Response:
|
|
85
|
+
request_id = _request_id_from_context(context)
|
|
86
|
+
response = drf_exception_handler(exc, context)
|
|
87
|
+
if response is None:
|
|
88
|
+
unhandled = True
|
|
89
|
+
response = Response(status=500)
|
|
90
|
+
else:
|
|
91
|
+
unhandled = False
|
|
92
|
+
|
|
93
|
+
status_code = response.status_code
|
|
94
|
+
message, details = _public_error_parts(response.data, status_code)
|
|
95
|
+
response.data = build_error_payload(
|
|
96
|
+
code=error_code_for_status(status_code),
|
|
97
|
+
message=message,
|
|
98
|
+
request_id=request_id,
|
|
99
|
+
details=details,
|
|
100
|
+
)
|
|
101
|
+
response[REQUEST_ID_HEADER] = request_id
|
|
102
|
+
|
|
103
|
+
request = context.get("request")
|
|
104
|
+
extra = {
|
|
105
|
+
"event": "api.error",
|
|
106
|
+
"request_id": request_id,
|
|
107
|
+
"method": getattr(request, "method", None),
|
|
108
|
+
"route": getattr(request, "path", None),
|
|
109
|
+
"status_code": status_code,
|
|
110
|
+
}
|
|
111
|
+
if unhandled:
|
|
112
|
+
logger.exception("api.unhandled_exception", exc_info=exc, extra=extra)
|
|
113
|
+
else:
|
|
114
|
+
logger.warning("api.request_error", extra=extra)
|
|
115
|
+
return response
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from rest_framework import serializers
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HealthSerializer(serializers.Serializer[dict[str, object]]):
|
|
5
|
+
status = serializers.CharField()
|
|
6
|
+
version = serializers.CharField()
|
|
7
|
+
request_id = serializers.CharField()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DependencyCheckSerializer(serializers.Serializer[dict[str, object]]):
|
|
11
|
+
status = serializers.CharField()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReadinessSerializer(HealthSerializer):
|
|
15
|
+
checks = serializers.DictField(child=DependencyCheckSerializer())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DiagnosticsSerializer(serializers.Serializer[dict[str, object]]):
|
|
19
|
+
status = serializers.CharField()
|
|
20
|
+
service = serializers.CharField()
|
|
21
|
+
environment = serializers.CharField()
|
|
22
|
+
release = serializers.CharField(allow_null=True)
|
|
23
|
+
platform_version = serializers.CharField()
|
|
24
|
+
django_version = serializers.CharField()
|
|
25
|
+
request_id = serializers.CharField()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import django
|
|
5
|
+
from baltimore.patapsco import __version__
|
|
6
|
+
from baltimore.patapsco.api.serializers import (
|
|
7
|
+
DiagnosticsSerializer,
|
|
8
|
+
HealthSerializer,
|
|
9
|
+
ReadinessSerializer,
|
|
10
|
+
)
|
|
11
|
+
from baltimore.patapsco.observability import get_request_id, new_request_id
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
from django.db import connection
|
|
14
|
+
from drf_spectacular.utils import extend_schema
|
|
15
|
+
from rest_framework.permissions import AllowAny, IsAdminUser
|
|
16
|
+
from rest_framework.request import Request
|
|
17
|
+
from rest_framework.response import Response
|
|
18
|
+
from rest_framework.views import APIView
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("baltimore.patapsco.health")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _request_id(request: Request) -> str:
|
|
24
|
+
value = getattr(request, "request_id", None)
|
|
25
|
+
return value if isinstance(value, str) and value else get_request_id() or new_request_id()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HealthView(APIView):
|
|
29
|
+
authentication_classes: list[type] = []
|
|
30
|
+
permission_classes = [AllowAny]
|
|
31
|
+
|
|
32
|
+
@extend_schema(responses={200: HealthSerializer})
|
|
33
|
+
def get(self, request: Request) -> Response:
|
|
34
|
+
return Response(
|
|
35
|
+
{
|
|
36
|
+
"status": "ok",
|
|
37
|
+
"version": __version__,
|
|
38
|
+
"request_id": _request_id(request),
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReadinessView(APIView):
|
|
44
|
+
authentication_classes: list[type] = []
|
|
45
|
+
permission_classes = [AllowAny]
|
|
46
|
+
|
|
47
|
+
@extend_schema(responses={200: ReadinessSerializer, 503: ReadinessSerializer})
|
|
48
|
+
def get(self, request: Request) -> Response:
|
|
49
|
+
database_status = "ok"
|
|
50
|
+
status_code = 200
|
|
51
|
+
try:
|
|
52
|
+
with connection.cursor() as cursor:
|
|
53
|
+
cursor.execute("SELECT 1")
|
|
54
|
+
cursor.fetchone()
|
|
55
|
+
except Exception:
|
|
56
|
+
database_status = "unavailable"
|
|
57
|
+
status_code = 503
|
|
58
|
+
logger.exception(
|
|
59
|
+
"readiness.database_unavailable",
|
|
60
|
+
extra={"event": "readiness.database_unavailable"},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return Response(
|
|
64
|
+
{
|
|
65
|
+
"status": "ok" if status_code == 200 else "unavailable",
|
|
66
|
+
"version": __version__,
|
|
67
|
+
"request_id": _request_id(request),
|
|
68
|
+
"checks": {"database": {"status": database_status}},
|
|
69
|
+
},
|
|
70
|
+
status=status_code,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DiagnosticsView(APIView):
|
|
75
|
+
def get_permissions(self) -> list[AllowAny | IsAdminUser]:
|
|
76
|
+
permission_class = (
|
|
77
|
+
AllowAny if getattr(settings, "PATAPSCO_DIAGNOSTICS_PUBLIC", False) else IsAdminUser
|
|
78
|
+
)
|
|
79
|
+
return [permission_class()]
|
|
80
|
+
|
|
81
|
+
@extend_schema(responses={200: DiagnosticsSerializer})
|
|
82
|
+
def get(self, request: Request) -> Response:
|
|
83
|
+
return Response(
|
|
84
|
+
{
|
|
85
|
+
"status": "ok",
|
|
86
|
+
"service": getattr(settings, "PATAPSCO_SERVICE_NAME", "django-app"),
|
|
87
|
+
"environment": os.getenv("APP_ENV", "local"),
|
|
88
|
+
"release": os.getenv("APP_RELEASE") or None,
|
|
89
|
+
"platform_version": __version__,
|
|
90
|
+
"django_version": django.get_version(),
|
|
91
|
+
"request_id": _request_id(request),
|
|
92
|
+
}
|
|
93
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import NotRequired, TypedDict
|
|
2
|
+
|
|
3
|
+
REQUEST_ID_HEADER = "X-Request-ID"
|
|
4
|
+
CLIENT_SURFACE_HEADER = "X-Client-Surface"
|
|
5
|
+
UPLOAD_ATTEMPT_ID_HEADER = "X-Upload-Attempt-ID"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorBody(TypedDict):
|
|
9
|
+
code: str
|
|
10
|
+
message: str
|
|
11
|
+
request_id: str
|
|
12
|
+
details: NotRequired[object]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorEnvelope(TypedDict):
|
|
16
|
+
error: ErrorBody
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
STATUS_ERROR_CODES: dict[int, str] = {
|
|
20
|
+
400: "validation_error",
|
|
21
|
+
401: "unauthorized",
|
|
22
|
+
403: "forbidden",
|
|
23
|
+
404: "not_found",
|
|
24
|
+
409: "conflict",
|
|
25
|
+
429: "throttled",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def error_code_for_status(status_code: int) -> str:
|
|
30
|
+
if status_code >= 500:
|
|
31
|
+
return "internal_error"
|
|
32
|
+
return STATUS_ERROR_CODES.get(status_code, "request_error")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from baltimore.patapsco.observability.context import (
|
|
2
|
+
bind_log_context,
|
|
3
|
+
get_log_context,
|
|
4
|
+
get_request_id,
|
|
5
|
+
new_request_id,
|
|
6
|
+
normalize_request_id,
|
|
7
|
+
request_context,
|
|
8
|
+
)
|
|
9
|
+
from baltimore.patapsco.observability.logging import (
|
|
10
|
+
JsonFormatter,
|
|
11
|
+
PrettyFormatter,
|
|
12
|
+
RequestContextFilter,
|
|
13
|
+
build_logging_config,
|
|
14
|
+
resolve_log_format,
|
|
15
|
+
)
|
|
16
|
+
from baltimore.patapsco.observability.middleware import RequestContextMiddleware
|
|
17
|
+
from baltimore.patapsco.observability.sentry import configure_sentry
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"JsonFormatter",
|
|
21
|
+
"PrettyFormatter",
|
|
22
|
+
"RequestContextFilter",
|
|
23
|
+
"RequestContextMiddleware",
|
|
24
|
+
"bind_log_context",
|
|
25
|
+
"build_logging_config",
|
|
26
|
+
"configure_sentry",
|
|
27
|
+
"get_log_context",
|
|
28
|
+
"get_request_id",
|
|
29
|
+
"new_request_id",
|
|
30
|
+
"normalize_request_id",
|
|
31
|
+
"request_context",
|
|
32
|
+
"resolve_log_format",
|
|
33
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Iterator, Mapping
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
8
|
+
_request_id: ContextVar[str | None] = ContextVar("baltimore_patapsco_request_id", default=None)
|
|
9
|
+
_log_context: ContextVar[dict[str, object] | None] = ContextVar(
|
|
10
|
+
"baltimore_patapsco_log_context", default=None
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_request_id(value: str | None) -> str | None:
|
|
15
|
+
if value is None:
|
|
16
|
+
return None
|
|
17
|
+
candidate = value.strip()
|
|
18
|
+
return candidate if _REQUEST_ID_PATTERN.fullmatch(candidate) else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def new_request_id() -> str:
|
|
22
|
+
return str(uuid4())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_request_id() -> str | None:
|
|
26
|
+
return _request_id.get()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_log_context() -> dict[str, object]:
|
|
30
|
+
return dict(_log_context.get() or {})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def bind_log_context(**values: object) -> None:
|
|
34
|
+
context = get_log_context()
|
|
35
|
+
context.update({key: value for key, value in values.items() if value is not None})
|
|
36
|
+
_log_context.set(context)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def request_context(
|
|
41
|
+
request_id: str,
|
|
42
|
+
values: Mapping[str, object] | None = None,
|
|
43
|
+
) -> Iterator[None]:
|
|
44
|
+
request_token = _request_id.set(request_id)
|
|
45
|
+
context_token = _log_context.set(dict(values or {}))
|
|
46
|
+
try:
|
|
47
|
+
yield
|
|
48
|
+
finally:
|
|
49
|
+
_log_context.reset(context_token)
|
|
50
|
+
_request_id.reset(request_token)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from baltimore.patapsco.observability.context import get_log_context, get_request_id
|
|
9
|
+
|
|
10
|
+
_CONTEXT_FIELDS = (
|
|
11
|
+
"event",
|
|
12
|
+
"request_id",
|
|
13
|
+
"method",
|
|
14
|
+
"route",
|
|
15
|
+
"status_code",
|
|
16
|
+
"latency_ms",
|
|
17
|
+
"user_id",
|
|
18
|
+
"client_surface",
|
|
19
|
+
"upload_attempt_id",
|
|
20
|
+
)
|
|
21
|
+
_LOG_RECORD_FIELDS = frozenset(logging.makeLogRecord({}).__dict__)
|
|
22
|
+
_SENSITIVE_KEY_PARTS = ("authorization", "cookie", "password", "secret", "token", "api_key")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _safe_custom_context(record: logging.LogRecord) -> dict[str, object]:
|
|
26
|
+
context: dict[str, object] = {}
|
|
27
|
+
for key, value in record.__dict__.items():
|
|
28
|
+
if (
|
|
29
|
+
key in _LOG_RECORD_FIELDS
|
|
30
|
+
or key in _CONTEXT_FIELDS
|
|
31
|
+
or key == "context"
|
|
32
|
+
or key.startswith("_")
|
|
33
|
+
):
|
|
34
|
+
continue
|
|
35
|
+
normalized_key = key.lower()
|
|
36
|
+
context[key] = (
|
|
37
|
+
"[REDACTED]" if any(part in normalized_key for part in _SENSITIVE_KEY_PARTS) else value
|
|
38
|
+
)
|
|
39
|
+
explicit_context = getattr(record, "context", None)
|
|
40
|
+
if isinstance(explicit_context, Mapping):
|
|
41
|
+
for key, value in explicit_context.items():
|
|
42
|
+
normalized_key = str(key).lower()
|
|
43
|
+
context[str(key)] = (
|
|
44
|
+
"[REDACTED]"
|
|
45
|
+
if any(part in normalized_key for part in _SENSITIVE_KEY_PARTS)
|
|
46
|
+
else value
|
|
47
|
+
)
|
|
48
|
+
return context
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RequestContextFilter(logging.Filter):
|
|
52
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
53
|
+
context = get_log_context()
|
|
54
|
+
context.setdefault("request_id", get_request_id())
|
|
55
|
+
for key, value in context.items():
|
|
56
|
+
if value is not None and not hasattr(record, key):
|
|
57
|
+
setattr(record, key, value)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JsonFormatter(logging.Formatter):
|
|
62
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
63
|
+
payload: dict[str, object] = {
|
|
64
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(
|
|
65
|
+
timespec="milliseconds"
|
|
66
|
+
),
|
|
67
|
+
"level": record.levelname.lower(),
|
|
68
|
+
"logger": record.name,
|
|
69
|
+
"message": record.getMessage(),
|
|
70
|
+
}
|
|
71
|
+
for field in _CONTEXT_FIELDS:
|
|
72
|
+
value = getattr(record, field, None)
|
|
73
|
+
if value is not None:
|
|
74
|
+
payload[field] = value
|
|
75
|
+
custom_context = _safe_custom_context(record)
|
|
76
|
+
if custom_context:
|
|
77
|
+
payload["context"] = custom_context
|
|
78
|
+
if record.exc_info:
|
|
79
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
80
|
+
if record.stack_info:
|
|
81
|
+
payload["stack"] = self.formatStack(record.stack_info)
|
|
82
|
+
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PrettyFormatter(logging.Formatter):
|
|
86
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
87
|
+
timestamp = datetime.fromtimestamp(record.created, tz=UTC).strftime("%H:%M:%S")
|
|
88
|
+
request_id = getattr(record, "request_id", None)
|
|
89
|
+
context_parts = [f"request_id={request_id}"] if request_id else []
|
|
90
|
+
for field in ("method", "route", "status_code", "latency_ms"):
|
|
91
|
+
value = getattr(record, field, None)
|
|
92
|
+
if value is not None:
|
|
93
|
+
context_parts.append(f"{field}={value}")
|
|
94
|
+
request_suffix = f" {' '.join(context_parts)}" if context_parts else ""
|
|
95
|
+
rendered = (
|
|
96
|
+
f"{timestamp} {record.levelname:<8} {record.name}{request_suffix} {record.getMessage()}"
|
|
97
|
+
)
|
|
98
|
+
if record.exc_info:
|
|
99
|
+
rendered = f"{rendered}\n{self.formatException(record.exc_info)}"
|
|
100
|
+
return rendered
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve_log_format(value: str | None = None) -> str:
|
|
104
|
+
resolved = (value or os.getenv("APP_LOG_FORMAT") or "json").strip().lower()
|
|
105
|
+
if resolved not in {"json", "pretty"}:
|
|
106
|
+
raise ValueError("APP_LOG_FORMAT must be 'json' or 'pretty'")
|
|
107
|
+
return resolved
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_logging_config(
|
|
111
|
+
*, log_format: str | None = None, level: str | None = None
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
formatter_name = resolve_log_format(log_format)
|
|
114
|
+
log_level = (level or os.getenv("APP_LOG_LEVEL") or "INFO").strip().upper()
|
|
115
|
+
return {
|
|
116
|
+
"version": 1,
|
|
117
|
+
"disable_existing_loggers": False,
|
|
118
|
+
"filters": {
|
|
119
|
+
"request_context": {
|
|
120
|
+
"()": "baltimore.patapsco.observability.logging.RequestContextFilter"
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"formatters": {
|
|
124
|
+
"json": {"()": "baltimore.patapsco.observability.logging.JsonFormatter"},
|
|
125
|
+
"pretty": {"()": "baltimore.patapsco.observability.logging.PrettyFormatter"},
|
|
126
|
+
},
|
|
127
|
+
"handlers": {
|
|
128
|
+
"console": {
|
|
129
|
+
"class": "logging.StreamHandler",
|
|
130
|
+
"filters": ["request_context"],
|
|
131
|
+
"formatter": formatter_name,
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"root": {"handlers": ["console"], "level": log_level},
|
|
135
|
+
"loggers": {
|
|
136
|
+
"django.server": {
|
|
137
|
+
"handlers": ["console"],
|
|
138
|
+
"level": log_level,
|
|
139
|
+
"propagate": False,
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from contextlib import AbstractContextManager
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
8
|
+
from baltimore.patapsco.contracts import CLIENT_SURFACE_HEADER, REQUEST_ID_HEADER
|
|
9
|
+
from baltimore.patapsco.observability.context import (
|
|
10
|
+
bind_log_context,
|
|
11
|
+
new_request_id,
|
|
12
|
+
normalize_request_id,
|
|
13
|
+
request_context,
|
|
14
|
+
)
|
|
15
|
+
from django.http import HttpRequest, HttpResponse
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("baltimore.patapsco.request")
|
|
18
|
+
|
|
19
|
+
SyncResponse = Callable[[HttpRequest], HttpResponse]
|
|
20
|
+
AsyncResponse = Callable[[HttpRequest], Awaitable[HttpResponse]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RequestContextMiddleware:
|
|
24
|
+
sync_capable = True
|
|
25
|
+
async_capable = True
|
|
26
|
+
|
|
27
|
+
def __init__(self, get_response: SyncResponse | AsyncResponse) -> None:
|
|
28
|
+
self.get_response = get_response
|
|
29
|
+
self.is_async = iscoroutinefunction(get_response)
|
|
30
|
+
if self.is_async:
|
|
31
|
+
markcoroutinefunction(self)
|
|
32
|
+
|
|
33
|
+
def __call__(self, request: HttpRequest) -> HttpResponse | Awaitable[HttpResponse]:
|
|
34
|
+
if self.is_async:
|
|
35
|
+
return self.__acall__(request)
|
|
36
|
+
return self._handle_sync(request)
|
|
37
|
+
|
|
38
|
+
def _begin(self, request: HttpRequest) -> tuple[str, float, AbstractContextManager[None]]:
|
|
39
|
+
request_id = (
|
|
40
|
+
normalize_request_id(request.headers.get(REQUEST_ID_HEADER)) or new_request_id()
|
|
41
|
+
)
|
|
42
|
+
request.request_id = request_id # type: ignore[attr-defined]
|
|
43
|
+
values: dict[str, object] = {
|
|
44
|
+
"method": request.method,
|
|
45
|
+
"route": request.path,
|
|
46
|
+
}
|
|
47
|
+
client_surface = request.headers.get(CLIENT_SURFACE_HEADER)
|
|
48
|
+
if client_surface:
|
|
49
|
+
values["client_surface"] = client_surface[:128]
|
|
50
|
+
return request_id, perf_counter(), request_context(request_id, values)
|
|
51
|
+
|
|
52
|
+
def _finish(
|
|
53
|
+
self,
|
|
54
|
+
request: HttpRequest,
|
|
55
|
+
response: HttpResponse,
|
|
56
|
+
request_id: str,
|
|
57
|
+
started_at: float,
|
|
58
|
+
) -> HttpResponse:
|
|
59
|
+
latency_ms = round((perf_counter() - started_at) * 1000, 2)
|
|
60
|
+
user = getattr(request, "user", None)
|
|
61
|
+
user_id = getattr(user, "pk", None) if getattr(user, "is_authenticated", False) else None
|
|
62
|
+
bind_log_context(
|
|
63
|
+
event="request.complete",
|
|
64
|
+
status_code=response.status_code,
|
|
65
|
+
latency_ms=latency_ms,
|
|
66
|
+
user_id=user_id,
|
|
67
|
+
)
|
|
68
|
+
response[REQUEST_ID_HEADER] = request_id
|
|
69
|
+
logger.info("request.complete")
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
def _log_exception(self, started_at: float) -> None:
|
|
73
|
+
bind_log_context(
|
|
74
|
+
event="request.exception",
|
|
75
|
+
latency_ms=round((perf_counter() - started_at) * 1000, 2),
|
|
76
|
+
)
|
|
77
|
+
logger.exception("request.exception")
|
|
78
|
+
|
|
79
|
+
def _handle_sync(self, request: HttpRequest) -> HttpResponse:
|
|
80
|
+
request_id, started_at, context = self._begin(request)
|
|
81
|
+
with context:
|
|
82
|
+
try:
|
|
83
|
+
response = cast(SyncResponse, self.get_response)(request)
|
|
84
|
+
except Exception:
|
|
85
|
+
self._log_exception(started_at)
|
|
86
|
+
raise
|
|
87
|
+
return self._finish(request, response, request_id, started_at)
|
|
88
|
+
|
|
89
|
+
async def __acall__(self, request: HttpRequest) -> HttpResponse:
|
|
90
|
+
request_id, started_at, context = self._begin(request)
|
|
91
|
+
with context:
|
|
92
|
+
try:
|
|
93
|
+
response = await cast(AsyncResponse, self.get_response)(request)
|
|
94
|
+
except Exception:
|
|
95
|
+
self._log_exception(started_at)
|
|
96
|
+
raise
|
|
97
|
+
return self._finish(request, response, request_id, started_at)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def configure_sentry(
|
|
7
|
+
*,
|
|
8
|
+
dsn: str | None = None,
|
|
9
|
+
environment: str | None = None,
|
|
10
|
+
release: str | None = None,
|
|
11
|
+
traces_sample_rate: float | None = None,
|
|
12
|
+
profiles_sample_rate: float | None = None,
|
|
13
|
+
) -> bool:
|
|
14
|
+
resolved_dsn = dsn if dsn is not None else os.getenv("SENTRY_DSN", "")
|
|
15
|
+
if not resolved_dsn:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import sentry_sdk
|
|
20
|
+
from sentry_sdk.integrations.django import DjangoIntegration
|
|
21
|
+
from sentry_sdk.integrations.logging import LoggingIntegration
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"Install baltimore-patapsco[observability] before configuring Sentry"
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
options: dict[str, Any] = {
|
|
28
|
+
"dsn": resolved_dsn,
|
|
29
|
+
"environment": environment or os.getenv("APP_ENV", "local"),
|
|
30
|
+
"release": release or os.getenv("APP_RELEASE") or None,
|
|
31
|
+
"send_default_pii": False,
|
|
32
|
+
"integrations": [DjangoIntegration(), LoggingIntegration(event_level=logging.ERROR)],
|
|
33
|
+
}
|
|
34
|
+
if traces_sample_rate is not None:
|
|
35
|
+
options["traces_sample_rate"] = traces_sample_rate
|
|
36
|
+
if profiles_sample_rate is not None:
|
|
37
|
+
options["profiles_sample_rate"] = profiles_sample_rate
|
|
38
|
+
sentry_sdk.init(**options)
|
|
39
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from baltimore.patapsco.api.views import DiagnosticsView, HealthView, ReadinessView
|
|
2
|
+
from django.urls import path
|
|
3
|
+
|
|
4
|
+
app_name = "baltimore.patapsco"
|
|
5
|
+
|
|
6
|
+
urlpatterns = [
|
|
7
|
+
path("healthz", HealthView.as_view(), name="health"),
|
|
8
|
+
path("readyz", ReadinessView.as_view(), name="readiness"),
|
|
9
|
+
path("api/diagnostics", DiagnosticsView.as_view(), name="diagnostics"),
|
|
10
|
+
]
|