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.
@@ -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,7 @@
1
+ """Shared Django runtime foundations for Baltimore civic applications."""
2
+
3
+ from baltimore.patapsco.contracts import REQUEST_ID_HEADER
4
+
5
+ __all__ = ["REQUEST_ID_HEADER", "__version__"]
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ from baltimore.patapsco.api.errors import (
2
+ api_error_response,
3
+ build_error_payload,
4
+ exception_handler,
5
+ )
6
+
7
+ __all__ = ["api_error_response", "build_error_payload", "exception_handler"]
@@ -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,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class BaltPlatformConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "baltimore.patapsco"
7
+ verbose_name = "Baltimore Platform"
@@ -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
@@ -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
+ ]