django-activity-audit 1.3.0.dev17__tar.gz → 1.3.0.dev19__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.
Files changed (31) hide show
  1. django_activity_audit-1.3.0.dev19/.claude/settings.local.json +7 -0
  2. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/PKG-INFO +4 -1
  3. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/__init__.py +6 -3
  4. django_activity_audit-1.3.0.dev19/activity_audit/apps.py +17 -0
  5. django_activity_audit-1.3.0.dev19/activity_audit/config.py +66 -0
  6. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/constants.py +0 -1
  7. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/formatters.py +20 -5
  8. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/handlers.py +14 -8
  9. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/middleware.py +21 -29
  10. django_activity_audit-1.3.0.dev19/activity_audit/shared_processors.py +128 -0
  11. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/signals.py +25 -23
  12. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/utils.py +4 -7
  13. django_activity_audit-1.3.0.dev19/docs/hipaa-audit-gaps.md +133 -0
  14. django_activity_audit-1.3.0.dev19/docs/structlog-integration.md +419 -0
  15. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/pyproject.toml +3 -1
  16. django_activity_audit-1.3.0.dev17/activity_audit/apps.py +0 -22
  17. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/.gitignore +0 -0
  18. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/.pre-commit-config.yaml +0 -0
  19. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/LICENSE +0 -0
  20. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/MANIFEST.in +0 -0
  21. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/README.md +0 -0
  22. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/README.rst +0 -0
  23. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/logger_levels.py +0 -0
  24. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/protocols.py +0 -0
  25. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/settings.py +0 -0
  26. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/activity_audit/unregistered.py +0 -0
  27. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/docs/django-activity-audit.md +0 -0
  28. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/docs/improvements.md +0 -0
  29. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/docs/user-activity-feed-plan.md +0 -0
  30. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/hatch +0 -0
  31. {django_activity_audit-1.3.0.dev17 → django_activity_audit-1.3.0.dev19}/pytest.ini +0 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(.venv/bin/python -m pip install \"structlog>=24.0\")"
5
+ ]
6
+ }
7
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-activity-audit
3
- Version: 1.3.0.dev17
3
+ Version: 1.3.0.dev19
4
4
  Summary: A Django package for easy CRUD operation logging and container logs
5
5
  Project-URL: Homepage, https://github.com/shree256/django-activity-audit
6
6
  Project-URL: Repository, https://github.com/shree256/django-activity-audit
@@ -30,12 +30,15 @@ Provides-Extra: dev
30
30
  Requires-Dist: pre-commit>=3.5; (python_version >= '3.9') and extra == 'dev'
31
31
  Requires-Dist: pre-commit~=3.5; (python_version < '3.9') and extra == 'dev'
32
32
  Requires-Dist: ruff>=0.1.11; extra == 'dev'
33
+ Provides-Extra: structlog
34
+ Requires-Dist: structlog>=24.0; extra == 'structlog'
33
35
  Provides-Extra: test
34
36
  Requires-Dist: djangorestframework>=3.14.0; extra == 'test'
35
37
  Requires-Dist: factory-boy>=3.2.0; extra == 'test'
36
38
  Requires-Dist: pytest-cov>=4.0.0; extra == 'test'
37
39
  Requires-Dist: pytest-django>=4.0.0; extra == 'test'
38
40
  Requires-Dist: pytest>=6.0.0; extra == 'test'
41
+ Requires-Dist: structlog>=24.0; extra == 'test'
39
42
  Description-Content-Type: text/markdown
40
43
 
41
44
  # Django Activity Audit
@@ -2,20 +2,22 @@ default_app_config = "activity_audit.apps.AuditLoggingConfig"
2
2
 
3
3
  from activity_audit.constants import LogType
4
4
  from activity_audit.utils import (
5
+ get_api_formatter,
5
6
  get_api_handler,
7
+ get_app_formatter,
6
8
  get_async_api_handler,
7
9
  get_async_audit_handler,
8
10
  get_async_json_handler,
9
11
  get_async_login_handler,
12
+ get_audit_formatter,
10
13
  get_audit_handler,
11
14
  get_console_formatter,
12
- get_app_formatter,
13
- get_api_formatter,
14
- get_audit_formatter,
15
15
  get_json_handler,
16
16
  get_login_handler,
17
17
  )
18
18
 
19
+ from .config import get_logger
20
+
19
21
  from . import logger_levels
20
22
 
21
23
  __all__ = [
@@ -32,4 +34,5 @@ __all__ = [
32
34
  "get_async_api_handler",
33
35
  "get_async_audit_handler",
34
36
  "get_async_login_handler",
37
+ "get_logger",
35
38
  ]
@@ -0,0 +1,17 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class AuditLoggingConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "activity_audit"
7
+ verbose_name = "Django Activity Audit"
8
+
9
+ def ready(self):
10
+ from . import (
11
+ logger_levels,
12
+ signals, # noqa
13
+ unregistered, # noqa
14
+ )
15
+ from .config import configure
16
+
17
+ configure()
@@ -0,0 +1,66 @@
1
+ import structlog
2
+
3
+ from activity_audit.shared_processors import (
4
+ _json_default,
5
+ _orjson_dumps,
6
+ shared_processors,
7
+ )
8
+
9
+
10
+ class AuditBoundLogger(structlog.stdlib.BoundLogger):
11
+ """Extends structlog BoundLogger with AUDIT, API, and LOGIN log levels."""
12
+
13
+ def audit(self, event=None, *args, **kw):
14
+ return self._proxy_to_logger("audit", event, *args, **kw)
15
+
16
+ def api(self, event=None, *args, **kw):
17
+ return self._proxy_to_logger("api", event, *args, **kw)
18
+
19
+ def login(self, event=None, *args, **kw):
20
+ return self._proxy_to_logger("login", event, *args, **kw)
21
+
22
+
23
+ def get_stdlib_formatter() -> dict:
24
+ """
25
+ LOGGING formatter dict that routes stdlib loggers through the structlog
26
+ processor chain. Use for any logger that isn't a native structlog logger
27
+ (e.g. celery, django, uvicorn).
28
+ """
29
+ return {
30
+ "()": structlog.stdlib.ProcessorFormatter,
31
+ "processors": [
32
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
33
+ structlog.processors.JSONRenderer(
34
+ serializer=_orjson_dumps, default=_json_default
35
+ ),
36
+ ],
37
+ "foreign_pre_chain": shared_processors,
38
+ }
39
+
40
+
41
+ def configure() -> None:
42
+ """
43
+ Configure structlog for console-only JSON output.
44
+
45
+ Produces the same JSON structure as the existing AuditFormatter /
46
+ APIFormatter / LoginFormatter — same field names, same timestamp format,
47
+ same uppercase level names — but outputs to stdout via StreamHandler.
48
+ No file handlers are used.
49
+
50
+ merge_contextvars is wired in so Phase 2 (replacing thread-locals with
51
+ structlog contextvars in middleware) only requires middleware changes;
52
+ this function and signals.py do not need to change again.
53
+ """
54
+ structlog.configure(
55
+ processors=shared_processors
56
+ + [
57
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
58
+ ],
59
+ logger_factory=structlog.stdlib.LoggerFactory(),
60
+ wrapper_class=AuditBoundLogger,
61
+ cache_logger_on_first_use=True,
62
+ )
63
+
64
+
65
+ def get_logger(name: str) -> AuditBoundLogger:
66
+ return structlog.get_logger(name)
@@ -18,4 +18,3 @@ REQUEST_TYPES = [
18
18
  "internal",
19
19
  "external",
20
20
  ]
21
-
@@ -4,8 +4,10 @@ import json
4
4
  import logging
5
5
  import uuid
6
6
 
7
+ import structlog.contextvars as ctx
8
+
7
9
  from .constants import LogType
8
- from .middleware import get_request_id
10
+
9
11
 
10
12
  def _json_default(obj):
11
13
  """
@@ -32,7 +34,11 @@ def _json_default(obj):
32
34
 
33
35
 
34
36
  class AppFormatter(logging.Formatter):
35
- def __init__(self, log_type: str = LogType.APP, timestamp_format: str = "%Y-%m-%d %H:%M:%S.%f"):
37
+ def __init__(
38
+ self,
39
+ log_type: str = LogType.APP,
40
+ timestamp_format: str = "%Y-%m-%d %H:%M:%S.%f",
41
+ ):
36
42
  super().__init__()
37
43
  self.log_type = log_type
38
44
  self.timestamp_format = timestamp_format
@@ -50,7 +56,8 @@ class AppFormatter(logging.Formatter):
50
56
  "path": record.pathname,
51
57
  "module": record.module,
52
58
  "function": record.funcName,
53
- "request_id": getattr(record, "request_id", None) or get_request_id() or "",
59
+ "request_id": getattr(record, "request_id", None)
60
+ or ctx.get_contextvars().get("request_id", ""),
54
61
  "message": record.getMessage(),
55
62
  "exception": "",
56
63
  "log_type": self.log_type,
@@ -66,7 +73,11 @@ class AppFormatter(logging.Formatter):
66
73
  class APIFormatter(logging.Formatter):
67
74
  """Custom formatter for audit logs that ensures consistent JSON formatting."""
68
75
 
69
- def __init__(self, log_type: str = LogType.API, timestamp_format: str = "%Y-%m-%d %H:%M:%S.%f"):
76
+ def __init__(
77
+ self,
78
+ log_type: str = LogType.API,
79
+ timestamp_format: str = "%Y-%m-%d %H:%M:%S.%f",
80
+ ):
70
81
  super().__init__()
71
82
  self.log_type = log_type
72
83
  self.timestamp_format = timestamp_format
@@ -103,7 +114,11 @@ class APIFormatter(logging.Formatter):
103
114
 
104
115
 
105
116
  class AuditFormatter(logging.Formatter):
106
- def __init__(self, log_type: str = LogType.AUDIT, timestamp_format: str = "%Y-%m-%d %H:%M:%S.%f"):
117
+ def __init__(
118
+ self,
119
+ log_type: str = LogType.AUDIT,
120
+ timestamp_format: str = "%Y-%m-%d %H:%M:%S.%f",
121
+ ):
107
122
  super().__init__()
108
123
  self.log_type = log_type
109
124
  self.timestamp_format = timestamp_format
@@ -3,8 +3,14 @@ import queue
3
3
 
4
4
  from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
5
5
 
6
- from .formatters import APIFormatter, AuditFormatter, AppFormatter, LoginFormatter
7
- from .middleware import get_request_id
6
+ import structlog.contextvars as ctx
7
+
8
+ from .formatters import (
9
+ APIFormatter,
10
+ AppFormatter,
11
+ AuditFormatter,
12
+ LoginFormatter,
13
+ )
8
14
 
9
15
 
10
16
  class BaseAuditHandler(RotatingFileHandler):
@@ -110,10 +116,10 @@ class AsyncBaseAuditHandler(QueueHandler):
110
116
  self._listener.start()
111
117
 
112
118
  def prepare(self, record):
113
- # Capture request_id in the calling thread before the record is queued,
114
- # since thread-locals are not accessible from the QueueListener thread.
119
+ # Snapshot request_id from contextvars before the record is queued
120
+ # the QueueListener thread runs in a different context.
115
121
  record = super().prepare(record)
116
- record.request_id = get_request_id() or ""
122
+ record.request_id = ctx.get_contextvars().get("request_id", "")
117
123
  return record
118
124
 
119
125
  def close(self):
@@ -170,10 +176,10 @@ class AsyncJsonHandler(QueueHandler):
170
176
  self._listener.start()
171
177
 
172
178
  def prepare(self, record):
173
- # Capture request_id in the calling thread before the record is queued,
174
- # since thread-locals are not accessible from the QueueListener thread.
179
+ # Snapshot request_id from contextvars before the record is queued
180
+ # the QueueListener thread runs in a different context.
175
181
  record = super().prepare(record)
176
- record.request_id = get_request_id() or ""
182
+ record.request_id = ctx.get_contextvars().get("request_id", "")
177
183
  return record
178
184
 
179
185
  def close(self):
@@ -1,19 +1,25 @@
1
1
  import contextlib
2
2
  import json
3
- import logging
4
3
  import re
5
4
  import time
6
5
  import uuid
7
6
 
7
+ import structlog.contextvars as ctx
8
+
8
9
  from asgiref.local import Local
9
- from asgiref.sync import iscoroutinefunction, markcoroutinefunction, sync_to_async
10
+ from asgiref.sync import (
11
+ iscoroutinefunction,
12
+ markcoroutinefunction,
13
+ sync_to_async,
14
+ )
10
15
  from django.http import HttpResponse
11
16
  from django.utils.deprecation import MiddlewareMixin
12
17
 
18
+ from .config import get_logger
13
19
  from .constants import REQUEST_TYPES
14
20
  from .settings import REGISTERED_URLS, SERVICE_NAME, UNREGISTERED_URLS
15
21
 
16
- logger = logging.getLogger("audit.request")
22
+ _log = get_logger("audit.request")
17
23
 
18
24
  _thread_locals = Local()
19
25
 
@@ -33,14 +39,6 @@ def set_current_request(request):
33
39
  _thread_locals.request = request
34
40
 
35
41
 
36
- def get_request_id():
37
- return getattr(_thread_locals, "request_id", None)
38
-
39
-
40
- def set_request_id(request_id):
41
- _thread_locals.request_id = request_id
42
-
43
-
44
42
  def get_current_user():
45
43
  request = get_current_request()
46
44
  if request:
@@ -77,8 +75,6 @@ def get_user_details():
77
75
  def clear_request():
78
76
  with contextlib.suppress(AttributeError):
79
77
  del _thread_locals.request
80
- with contextlib.suppress(AttributeError):
81
- del _thread_locals.request_id
82
78
 
83
79
 
84
80
  def should_log_url(url):
@@ -135,7 +131,8 @@ class AuditLoggingMiddleware(MiddlewareMixin):
135
131
  return self.__acall__(request)
136
132
  set_current_request(request)
137
133
  request_id = str(uuid.uuid4())
138
- set_request_id(request_id)
134
+ ctx.clear_contextvars()
135
+ ctx.bind_contextvars(request_id=request_id)
139
136
 
140
137
  if not should_log_url(request.path):
141
138
  return self.get_response(request)
@@ -144,9 +141,6 @@ class AuditLoggingMiddleware(MiddlewareMixin):
144
141
  "service_name": SERVICE_NAME,
145
142
  "request_type": REQUEST_TYPES[0],
146
143
  "protocol": None,
147
- "request_id": "",
148
- "user_id": "",
149
- "user_info": {},
150
144
  "request_repr": {},
151
145
  "response_repr": {},
152
146
  "error_message": None,
@@ -175,8 +169,7 @@ class AuditLoggingMiddleware(MiddlewareMixin):
175
169
 
176
170
  # Capture user details AFTER authentication has happened
177
171
  user_id, user_info = get_user_details()
178
- log_data["user_id"] = user_id
179
- log_data["user_info"] = user_info
172
+ ctx.bind_contextvars(user_id=user_id, user_info=user_info)
180
173
 
181
174
  # TODO: Find way to add status code to response_data
182
175
 
@@ -198,20 +191,22 @@ class AuditLoggingMiddleware(MiddlewareMixin):
198
191
 
199
192
  log_data["execution_time"] = end_time - start_time
200
193
  log_data["protocol"] = "https" if request.is_secure() else "http"
201
- log_data["request_id"] = request_id
202
194
  log_data["request_repr"] = request_data
203
195
  log_data["response_repr"] = response_data
204
196
 
205
- logger.api("Audit Internal Request", extra=log_data)
197
+ bound = _log.bind(**log_data)
198
+ bound.api("Audit Internal Request")
206
199
 
207
200
  clear_request()
201
+ ctx.clear_contextvars()
208
202
 
209
203
  return response
210
204
 
211
205
  async def __acall__(self, request):
212
206
  set_current_request(request)
213
207
  request_id = str(uuid.uuid4())
214
- set_request_id(request_id)
208
+ ctx.clear_contextvars()
209
+ ctx.bind_contextvars(request_id=request_id)
215
210
 
216
211
  if not should_log_url(request.path):
217
212
  return await self.get_response(request)
@@ -220,9 +215,6 @@ class AuditLoggingMiddleware(MiddlewareMixin):
220
215
  "service_name": SERVICE_NAME,
221
216
  "request_type": REQUEST_TYPES[0],
222
217
  "protocol": None,
223
- "request_id": "",
224
- "user_id": "",
225
- "user_info": {},
226
218
  "request_repr": {},
227
219
  "response_repr": {},
228
220
  "error_message": None,
@@ -253,8 +245,7 @@ class AuditLoggingMiddleware(MiddlewareMixin):
253
245
  user_id, user_info = await sync_to_async(
254
246
  get_user_details, thread_sensitive=True
255
247
  )()
256
- log_data["user_id"] = user_id
257
- log_data["user_info"] = user_info
248
+ ctx.bind_contextvars(user_id=user_id, user_info=user_info)
258
249
 
259
250
  # TODO: Find way to add status code to response_data
260
251
 
@@ -276,12 +267,13 @@ class AuditLoggingMiddleware(MiddlewareMixin):
276
267
 
277
268
  log_data["execution_time"] = end_time - start_time
278
269
  log_data["protocol"] = "https" if request.is_secure() else "http"
279
- log_data["request_id"] = request_id
280
270
  log_data["request_repr"] = request_data
281
271
  log_data["response_repr"] = response_data
282
272
 
283
- logger.api("Audit Internal Request", extra=log_data)
273
+ bound = _log.bind(**log_data)
274
+ bound.api("Audit Internal Request")
284
275
 
285
276
  clear_request()
277
+ ctx.clear_contextvars()
286
278
 
287
279
  return response
@@ -0,0 +1,128 @@
1
+ import datetime
2
+ import decimal
3
+ import sys
4
+
5
+ import orjson
6
+ import structlog
7
+
8
+ from structlog.processors import CallsiteParameter
9
+
10
+ from activity_audit.constants import LogType
11
+
12
+
13
+ def _orjson_dumps(*args, **kwargs):
14
+ return orjson.dumps(*args, **kwargs).decode()
15
+
16
+
17
+ def _json_default(obj):
18
+ if isinstance(obj, decimal.Decimal):
19
+ return float(obj)
20
+ if isinstance(obj, set):
21
+ return list(obj)
22
+ if isinstance(obj, bytes):
23
+ return obj.decode("utf-8", errors="replace")
24
+ return str(obj)
25
+
26
+
27
+ def _audit_timestamp(logger, method, event_dict):
28
+ """Timestamp in the same format as the existing formatters: 'YYYY-MM-DD HH:MM:SS.mmm'."""
29
+ event_dict["timestamp"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[
30
+ :-3
31
+ ]
32
+ return event_dict
33
+
34
+
35
+ def _uppercase_level(logger, method, event_dict):
36
+ """Uppercase level name to match AUDIT / API / LOGIN convention."""
37
+ if "level" in event_dict:
38
+ event_dict["level"] = event_dict["level"].upper()
39
+ return event_dict
40
+
41
+
42
+ def _rename_structlog_keys(logger, method, event_dict):
43
+ """
44
+ Rename structlog default keys to match the existing JSON field names:
45
+ event → message
46
+ logger → name
47
+ """
48
+ if "event" in event_dict:
49
+ event_dict["message"] = event_dict.pop("event")
50
+ if "logger" in event_dict:
51
+ event_dict["name"] = event_dict.pop("logger")
52
+ return event_dict
53
+
54
+
55
+ def _detect_process_log_type() -> str:
56
+ argv = " ".join(sys.argv)
57
+ if "beat" in argv:
58
+ return LogType.CELERYBEAT
59
+ if "worker" in argv or "celery" in argv:
60
+ return LogType.CELERYWORKER
61
+ return LogType.AUDIT
62
+
63
+
64
+ _PROCESS_LOG_TYPE = _detect_process_log_type()
65
+
66
+ _LOG_TYPE_MAP = {
67
+ "audit.model": _PROCESS_LOG_TYPE,
68
+ "audit.request": LogType.API,
69
+ "audit.login": LogType.LOGIN,
70
+ }
71
+
72
+
73
+ def _add_log_type(logger, method, event_dict):
74
+ """Add log_type based on logger name; all celery.* loggers get the process type."""
75
+ name = event_dict.get("name", "")
76
+ if name in _LOG_TYPE_MAP:
77
+ event_dict["log_type"] = _LOG_TYPE_MAP[name]
78
+ elif name.startswith("celery"):
79
+ event_dict["log_type"] = _PROCESS_LOG_TYPE
80
+ else:
81
+ event_dict["log_type"] = LogType.APP
82
+ return event_dict
83
+
84
+
85
+ _STANDARD_KEYS = frozenset(
86
+ {
87
+ "timestamp",
88
+ "level",
89
+ "name",
90
+ "message",
91
+ "log_type",
92
+ "filename",
93
+ "func_name",
94
+ "stack_info",
95
+ "exception",
96
+ "request_id",
97
+ "_record",
98
+ "_from_structlog",
99
+ }
100
+ )
101
+
102
+
103
+ def _collect_extra(logger, method, event_dict):
104
+ """Move all caller-supplied kwargs that aren't standard fields into 'extra'."""
105
+ extra = {k: event_dict.pop(k) for k in list(event_dict) if k not in _STANDARD_KEYS}
106
+ if extra:
107
+ event_dict["extra"] = extra
108
+ return event_dict
109
+
110
+
111
+ shared_processors = [
112
+ structlog.contextvars.merge_contextvars,
113
+ structlog.stdlib.add_log_level,
114
+ structlog.stdlib.add_logger_name,
115
+ _audit_timestamp,
116
+ _uppercase_level,
117
+ _rename_structlog_keys,
118
+ _add_log_type,
119
+ structlog.processors.CallsiteParameterAdder(
120
+ [
121
+ CallsiteParameter.FILENAME,
122
+ CallsiteParameter.FUNC_NAME,
123
+ ]
124
+ ),
125
+ structlog.processors.StackInfoRenderer(),
126
+ structlog.processors.ExceptionRenderer(),
127
+ _collect_extra,
128
+ ]
@@ -1,9 +1,10 @@
1
1
  import inspect
2
- import logging
3
2
 
4
3
  from functools import wraps
5
4
  from typing import Any, List
6
5
 
6
+ import structlog.contextvars as ctx
7
+
7
8
  from django.apps import apps
8
9
  from django.db import models, transaction
9
10
  from django.db.models.signals import (
@@ -15,10 +16,10 @@ from django.db.models.signals import (
15
16
  from django.dispatch import receiver
16
17
  from django.forms.models import model_to_dict
17
18
 
18
- from activity_audit.middleware import get_request_id, get_user_details
19
+ from activity_audit.config import get_logger
19
20
  from activity_audit.unregistered import UNREGISTERED_CLASSES
20
21
 
21
- logger = logging.getLogger("audit.model")
22
+ _log = get_logger("audit.model")
22
23
 
23
24
  EVENT_TYPES = [
24
25
  "CREATE",
@@ -129,27 +130,29 @@ def push_log(
129
130
  extra: dict = {},
130
131
  ) -> None:
131
132
  try:
132
- user_id, user_info = get_user_details()
133
- payload: dict = {
134
- "model": model,
135
- "instance_id": str(instance_id),
136
- "event_type": event_type,
137
- "request_id": get_request_id() or "",
138
- "user_id": user_id,
139
- "user_info": user_info,
140
- "instance_repr": instance_repr,
141
- "extra": extra,
142
- }
133
+ # Snapshot contextvars now — on_commit fires after the transaction commits
134
+ # and the middleware may have already cleared contextvars by then.
135
+ ctx_vars = ctx.get_contextvars()
136
+ bound = _log.bind(
137
+ model=model,
138
+ instance_id=str(instance_id),
139
+ event_type=event_type,
140
+ request_id=ctx_vars.get("request_id", ""),
141
+ user_id=ctx_vars.get("user_id", ""),
142
+ user_info=ctx_vars.get("user_info", {}),
143
+ instance_repr=instance_repr,
144
+ extra=extra,
145
+ )
143
146
 
144
147
  def safe_audit_log():
145
148
  try:
146
- logger.audit(message, extra=payload)
149
+ bound.audit(message)
147
150
  except Exception as e:
148
- logger.error(f"Failed to write audit log: {e}")
151
+ _log.error("Failed to write audit log", error=str(e))
149
152
 
150
153
  transaction.on_commit(safe_audit_log)
151
154
  except Exception as e:
152
- logger.error(f"Failed to prepare audit log: {e}")
155
+ _log.error("Failed to prepare audit log", error=str(e))
153
156
 
154
157
 
155
158
  def instance_to_dict(instance: models.Model) -> dict:
@@ -256,23 +259,22 @@ def patch_model_event(model_class: type[models.Model]) -> None:
256
259
  instance: models.Model,
257
260
  action: str,
258
261
  pk_set: set,
262
+ _field_name: str = field.name,
259
263
  **kwargs: Any,
260
264
  ) -> None:
261
265
  if action not in ["post_add", "post_remove", "post_clear"]:
262
266
  return
263
267
 
264
- field_name = kwargs.get("model", sender).__name__.lower()
265
- instance_repr = instance_to_dict(instance)
266
-
267
268
  push_log(
268
269
  f"M2M {action} event by {model_class.__name__} (id: {instance.pk})",
269
270
  model_class.__name__,
270
271
  EVENT_TYPES[5],
271
272
  str(instance.pk),
272
- instance_repr,
273
+ {"id": str(instance.pk)},
273
274
  {
274
- "field_name": field_name,
275
- "related_ids": list(map(str, pk_set)) if pk_set else None,
275
+ "action": action,
276
+ "field_name": _field_name,
277
+ "related_ids": list(map(str, pk_set)) if pk_set else [],
276
278
  },
277
279
  )
278
280
 
@@ -13,15 +13,13 @@ def get_app_formatter(log_type: str = "app") -> dict:
13
13
  "log_type": log_type,
14
14
  }
15
15
 
16
+
16
17
  def get_api_formatter() -> dict:
17
- return {
18
- "()": "activity_audit.formatters.APIFormatter"
19
- }
18
+ return {"()": "activity_audit.formatters.APIFormatter"}
19
+
20
20
 
21
21
  def get_audit_formatter() -> dict:
22
- return {
23
- "()": "activity_audit.formatters.AuditFormatter"
24
- }
22
+ return {"()": "activity_audit.formatters.AuditFormatter"}
25
23
 
26
24
 
27
25
  def get_json_handler(
@@ -41,7 +39,6 @@ def get_json_handler(
41
39
  }
42
40
 
43
41
 
44
-
45
42
  def get_api_handler(
46
43
  filename: str = "audit_logs/api.log",
47
44
  ) -> dict:
@@ -0,0 +1,133 @@
1
+ # HIPAA Audit Gap Analysis
2
+
3
+ Architecture: Django → stdout → Docker logs → Vector → ClickHouse (self-hosted, in-VPC) → Grafana
4
+
5
+ ---
6
+
7
+ ## Critical — Must Fix
8
+
9
+ ### 1. Sentry Integration Shipping PHI Outside VPC — §164.314
10
+
11
+ `settings.py` maps AUDIT/API log levels to Sentry. If Sentry is active in production, full request/response payloads and model reprs containing patient data are leaving the VPC to a third-party with no BAA coverage.
12
+
13
+ **Fix:** Disable Sentry for `audit.*` loggers in production, or strip PHI fields before they reach the Sentry handler.
14
+
15
+ ---
16
+
17
+ ### 2. Response Status Code Missing from API Logs — §164.312(b)
18
+
19
+ `middleware.py:181` has a `# TODO` for status code. HIPAA requires distinguishing authorized access (200) from denied access (403/401) to identify unauthorized access attempts.
20
+
21
+ **Fix:** `response.status_code` is directly available on the Django `HttpResponse` object — add it to `response_repr`.
22
+
23
+ ---
24
+
25
+ ### 3. Log Integrity / Tamper-Evidence — §164.312(c)(1)
26
+
27
+ Audit logs must be protected from unauthorized alteration.
28
+
29
+ **Fix:**
30
+ - ClickHouse ingest user must have `INSERT` only — no `ALTER`/`DELETE`/`TRUNCATE`
31
+ - Docker daemon socket access restricted to the Vector service account only
32
+ - Separate admin-only ClickHouse role for schema changes
33
+
34
+ ---
35
+
36
+ ### 4. Log Retention Not Enforced — §164.530(j)
37
+
38
+ HIPAA requires audit log retention for 6 years (2190 days). `backupCount` on local file handlers is irrelevant with Docker log streaming, but ClickHouse TTL and Vector buffering are not configured.
39
+
40
+ **Fix:**
41
+ - Set ClickHouse TTL policy to 6+ years explicitly on audit tables
42
+ - Set Vector sink buffer to `type = "disk"` — the default in-memory buffer silently drops events when ClickHouse is unavailable
43
+
44
+ ---
45
+
46
+ ### 5. No Audit of Audit-Log Access — §164.312(b)
47
+
48
+ Grafana queries against ClickHouse constitute access to ePHI audit trails. Those accesses are not themselves logged or reviewed.
49
+
50
+ **Fix:** Enable ClickHouse query logging and retain Grafana access logs. Review them periodically as part of your access management program.
51
+
52
+ ---
53
+
54
+ ### 6. Failed Login Attempts Not Captured — §164.312(d)
55
+
56
+ `push_usage_log` only runs when explicitly called by the application. Django's `user_login_failed` signal is not wired, so brute-force attacks against patient portals leave no trace in the audit trail.
57
+
58
+ **Fix:** Wire `user_login_failed` (and optionally `user_logged_out`) signals in `apps.py:ready()`.
59
+
60
+ ---
61
+
62
+ ### 7. Admin Interface Excluded from Logging — §164.308(a)(1)
63
+
64
+ `UNREGISTERED_URLS` defaults include `r"^/admin/"` and `LogEntry` is in `UNREGISTERED_CLASSES`. Superuser admin actions carry the highest insider-threat risk and must be in the audit trail.
65
+
66
+ **Fix:** Remove `^/admin/` from the default exclusion list and remove `LogEntry` from `UNREGISTERED_CLASSES`, or capture admin actions via a dedicated signal.
67
+
68
+ ---
69
+
70
+ ### 8. Acting User's `sex` and `date_of_birth` in Every Log Entry
71
+
72
+ `get_user_details()` in `middleware.py:66–73` captures `sex` and `date_of_birth` of the user performing the action — not the patient. This unnecessarily expands the ePHI footprint across every single log entry and is directly at risk from point 1 (Sentry).
73
+
74
+ **Fix:** Strip `sex` and `date_of_birth` from `get_user_details()`. Audit identity only needs `user_id`, `email`, and `role`.
75
+
76
+ ---
77
+
78
+ ## Medium Priority
79
+
80
+ ### 9. `pre_save` + `on_commit` Double-Logging
81
+
82
+ `handle_pre_save` in `signals.py` calls `push_log`, which wraps emission in `transaction.on_commit`. The pre-commit state semantic is lost since the log is deferred to after commit anyway. Every save emits two entries: PRE_CREATE + CREATE (or PRE_UPDATE + UPDATE), doubling ClickHouse storage for every write.
83
+
84
+ **Fix:** Remove the `pre_save` signals unless there is a documented use case for capturing pre-commit field state.
85
+
86
+ ---
87
+
88
+ ### 10. Bulk Operations Log Only the First Object's Repr
89
+
90
+ `signals.py:84–91` — `bulk_create_with_signals` logs `instance_repr` for `created_objs[0]` only, with a `total_count` field. If 500 patient records are bulk-created, the audit shows only one record's state.
91
+
92
+ **Fix:** Either log all object IDs in the `extra` field, or formally document that bulk operations are audited at the batch level only (not per-record).
93
+
94
+ ---
95
+
96
+ ### 11. SFTP Client Uses `AutoAddPolicy` — `protocols.py:131`
97
+
98
+ `paramiko.AutoAddPolicy()` accepts any host key silently, making PHI file transfers vulnerable to man-in-the-middle attacks.
99
+
100
+ **Fix:** Use `paramiko.RejectPolicy()` with explicitly pinned trusted host keys.
101
+
102
+ ---
103
+
104
+ ### 12. Celery / Async Context Loses User Identity Silently
105
+
106
+ Background Celery tasks that trigger model saves have no request context. `get_user_details()` returns `"", {}` silently — audit entries for async PHI processing have no actor identity, making them useless for accountability.
107
+
108
+ **Fix:** Explicitly inject the acting user into task context and call `set_current_user()` at the start of tasks that perform auditable operations.
109
+
110
+ ---
111
+
112
+ ## Architecture-Specific
113
+
114
+ ### 13. Silent Log Loss on Container Crash
115
+
116
+ `transaction.on_commit` in `signals.py:150` defers the audit log write until after the database commits. If the container crashes in the window between DB commit and stdout emission, the database change is committed but the audit event is never emitted — silently lost with no fallback.
117
+
118
+ **Fix:** Document this as a known gap in the threat model, or add a DB-side audit table as a secondary sink for critical events.
119
+
120
+ ---
121
+
122
+ ### 14. Vector Sink Buffer Not Configured for Durability
123
+
124
+ The default Vector buffer is in-memory. If ClickHouse is temporarily unavailable, events in the buffer are dropped with no recovery.
125
+
126
+ **Fix:** Set `type = "disk"` with a defined `max_size` in the Vector ClickHouse sink buffer configuration.
127
+
128
+ ```toml
129
+ [sinks.clickhouse.buffer]
130
+ type = "disk"
131
+ max_size = 268435488 # 256MB
132
+ when_full = "block"
133
+ ```
@@ -0,0 +1,419 @@
1
+ # Structlog Integration — Design & Migration Guide
2
+
3
+ > Authored: 2026-06-16
4
+ > Scope: Evaluating structlog as a replacement / complement to the stdlib `logging` layer in `django-activity-audit`
5
+
6
+ ---
7
+
8
+ ## Current Architecture (Baseline)
9
+
10
+ | Component | File | Responsibility |
11
+ |-----------|------|----------------|
12
+ | Custom log levels (`AUDIT=21`, `API=22`, `LOGIN=23`) | `logger_levels.py` | Monkey-patched onto `logging.Logger` |
13
+ | Named loggers (`audit.model`, `audit.request`, `audit.login`) | `signals.py:21`, `middleware.py:16`, `utils.py:150` | One logger per concern |
14
+ | Context carriers (`request_id`, `user_id`) | `middleware.py:8–82` | `asgiref.local.Local()` thread-locals |
15
+ | Payload construction | `signals.py:123–152` (`push_log`) | Manually builds dict, passed as `extra={}` |
16
+ | Formatters | `formatters.py` | Manually serialize `LogRecord.extra` to JSON |
17
+ | Handlers | `handlers.py` | `RotatingFileHandler` + async `QueueHandler` variants |
18
+
19
+ ### What push_log() does today
20
+
21
+ ```python
22
+ # signals.py:123-152
23
+ def push_log(message, model, event_type, instance_id, instance_repr, extra={}):
24
+ user_id, user_info = get_user_details() # pulls from thread-local
25
+ request_id = get_request_id() # pulls from thread-local
26
+ payload = {
27
+ "model": model,
28
+ "event_type": event_type,
29
+ "request_id": request_id or "",
30
+ "user_id": user_id,
31
+ "user_info": user_info,
32
+ "instance_id": str(instance_id),
33
+ "instance_repr": instance_repr,
34
+ "extra": extra,
35
+ }
36
+ transaction.on_commit(lambda: logger.audit(message, extra=payload))
37
+ ```
38
+
39
+ Every field is packed manually, every call site must import and invoke `get_request_id()` / `get_user_details()`.
40
+
41
+ ---
42
+
43
+ ## Pros of Structlog
44
+
45
+ ### 1. Context binding replaces manual dict packing
46
+
47
+ With structlog, fields bound at request entry flow automatically to every log call in that request — no explicit retrieval needed:
48
+
49
+ ```python
50
+ # middleware.py — bind once at request start
51
+ import structlog.contextvars as ctx
52
+
53
+ ctx.bind_contextvars(
54
+ request_id=request_id,
55
+ user_id=user_id,
56
+ user_info=user_info,
57
+ )
58
+
59
+ # signals.py — push_log becomes lean
60
+ log = structlog.get_logger("audit.model")
61
+
62
+ def push_log(message, model, event_type, instance_id, instance_repr, extra={}):
63
+ transaction.on_commit(lambda: log.audit(
64
+ message,
65
+ model=model,
66
+ event_type=event_type,
67
+ instance_id=str(instance_id),
68
+ instance_repr=instance_repr,
69
+ extra=extra,
70
+ ))
71
+ # request_id, user_id, user_info injected automatically by merge_contextvars
72
+ ```
73
+
74
+ ### 2. `structlog.contextvars` replaces `asgiref.local.Local()`
75
+
76
+ The entire `_thread_locals` block in `middleware.py:8–82` (`set_request_id`, `get_request_id`, `set_current_request`, `get_current_user`, `clear_request`, etc.) is replaced by three calls:
77
+
78
+ ```python
79
+ # request start
80
+ structlog.contextvars.bind_contextvars(request_id=uuid, user_id=uid, user_info=info)
81
+
82
+ # request end (in finally block)
83
+ structlog.contextvars.clear_contextvars()
84
+ ```
85
+
86
+ `contextvars` is async-native — it scopes to the current `asyncio.Task`, so concurrent async requests never bleed context into each other. The current `asgiref.local.Local()` approach requires careful `clear_request()` discipline to avoid leaks (see `improvements.md` — issue #7).
87
+
88
+ ### 3. Processor pipeline — composable and testable
89
+
90
+ The four monolithic formatters (`AppFormatter`, `APIFormatter`, `AuditFormatter`, `LoginFormatter`) in `formatters.py` each manually extract fields from `LogRecord` and serialize to JSON (~150 lines total). Structlog replaces them with a composable processor list:
91
+
92
+ ```python
93
+ import structlog
94
+ from structlog.processors import CallsiteParameter
95
+
96
+ shared_processors = [
97
+ structlog.contextvars.merge_contextvars, # inject request_id, user_id, user_info
98
+ structlog.processors.add_log_level, # adds "level" key
99
+ structlog.processors.TimeStamper(fmt="iso"), # ISO 8601 timestamp
100
+ structlog.processors.CallsiteParameterAdder([ # file + line — see below
101
+ CallsiteParameter.FILENAME,
102
+ CallsiteParameter.LINENO,
103
+ CallsiteParameter.FUNC_NAME,
104
+ ]),
105
+ structlog.processors.JSONRenderer(), # final JSON serialization
106
+ ]
107
+
108
+ structlog.configure(
109
+ processors=shared_processors,
110
+ wrapper_class=structlog.make_filtering_bound_logger(logging.DEBUG),
111
+ context_class=dict,
112
+ logger_factory=structlog.PrintLoggerFactory(),
113
+ )
114
+ ```
115
+
116
+ Each processor is a pure function — easy to test or swap individually. Adding a new field means appending one processor, not editing four formatters.
117
+
118
+ ### 4. `CallsiteParameterAdder` — file and line in every signal log
119
+
120
+ **This directly addresses the question: "when performing audit action in signals, can I get file local too along logger?"**
121
+
122
+ `CallsiteParameterAdder` walks the call stack once per log call and injects:
123
+
124
+ | Parameter | Captured value (example) |
125
+ |-----------|--------------------------|
126
+ | `FILENAME` | `signals.py` |
127
+ | `LINENO` | `146` |
128
+ | `FUNC_NAME` | `push_log` |
129
+ | `MODULE` | `signals` |
130
+ | `PATHNAME` | `/app/activity_audit/signals.py` |
131
+
132
+ No changes to `push_log()` — the processor chain handles it. Every `push_log()` call gets:
133
+
134
+ ```json
135
+ {
136
+ "timestamp": "2026-06-16T10:22:45.123Z",
137
+ "level": "audit",
138
+ "event": "CREATE event for Author (id: abc123)",
139
+ "filename": "signals.py",
140
+ "lineno": 146,
141
+ "func_name": "push_log",
142
+ "model": "Author",
143
+ "event_type": "CREATE",
144
+ "request_id": "f3c9a1b2-...",
145
+ "user_id": "cae8ffb4-..."
146
+ }
147
+ ```
148
+
149
+ > If you want the callsite that triggered `.save()` in user code (not `signals.py` itself), that requires `inspect.stack()` traversal. This is expensive and fragile — the signal-layer callsite is the correct and meaningful audit origin for most use cases.
150
+
151
+ ### 5. `structlog.testing.capture_logs()` — structured test assertions
152
+
153
+ Current tests assert against formatted log strings or check handler call counts. With structlog:
154
+
155
+ ```python
156
+ import structlog.testing
157
+
158
+ def test_create_event_logged(db):
159
+ with structlog.testing.capture_logs() as logs:
160
+ Author.objects.create(name="Test", bio="Bio")
161
+
162
+ audit_logs = [l for l in logs if l.get("event_type") == "CREATE"]
163
+ assert len(audit_logs) == 1
164
+ assert audit_logs[0]["model"] == "Author"
165
+ assert audit_logs[0]["event_type"] == "CREATE"
166
+ assert "instance_repr" in audit_logs[0]
167
+ ```
168
+
169
+ No string parsing. No mock handlers. The captured entries are plain dicts.
170
+
171
+ ### 6. Lazy rendering — zero overhead when filtered
172
+
173
+ `push_log()` today builds the full payload dict unconditionally, even if `AUDIT` level is filtered out by the handler. Structlog defers all dict construction and serialization until the processor chain confirms the level passes — filtered calls are nearly free.
174
+
175
+ ### 7. Per-logger context with `bind()`
176
+
177
+ Signal handlers can pre-bind model-level context at module load:
178
+
179
+ ```python
180
+ # signals.py
181
+ log = structlog.get_logger("audit.model")
182
+
183
+ def push_log(message, model, event_type, instance_id, instance_repr, extra={}):
184
+ bound = log.bind(model=model, event_type=event_type)
185
+ transaction.on_commit(
186
+ lambda: bound.audit(message, instance_id=str(instance_id), ...)
187
+ )
188
+ ```
189
+
190
+ The bound logger carries `model` and `event_type` into all subsequent calls — useful when a signal handler emits multiple events in sequence.
191
+
192
+ ---
193
+
194
+ ## Cons of Structlog
195
+
196
+ ### 1. Custom log levels are second-class citizens
197
+
198
+ `AUDIT=21`, `API=22`, `LOGIN=23` are monkey-patched onto `logging.Logger` in `logger_levels.py`. Structlog's `BoundLogger` has no `.audit()` / `.api()` / `.login()` methods by default.
199
+
200
+ **Mitigation:** Use `structlog.make_filtering_bound_logger()` with a custom wrapper:
201
+
202
+ ```python
203
+ import structlog
204
+
205
+ class AuditBoundLogger(structlog.stdlib.BoundLogger):
206
+ def audit(self, event, **kw):
207
+ return self._proxy_to_logger("audit", event, **kw)
208
+
209
+ def api(self, event, **kw):
210
+ return self._proxy_to_logger("api", event, **kw)
211
+
212
+ def login(self, event, **kw):
213
+ return self._proxy_to_logger("login", event, **kw)
214
+
215
+ structlog.configure(wrapper_class=AuditBoundLogger, ...)
216
+ ```
217
+
218
+ This requires registering the custom integer levels on the stdlib side first (same as now via `logger_levels.py`), then bridging through `structlog.stdlib.ProcessorFormatter`.
219
+
220
+ ### 2. Two-layer configuration when using stdlib handlers
221
+
222
+ If the existing `RotatingFileHandler` / `QueueHandler` chain is kept (to avoid rewriting the async handler infrastructure), both layers must be configured:
223
+
224
+ ```
225
+ structlog processor chain → stdlib logging.Logger → existing handlers
226
+ ```
227
+
228
+ The bridge is `structlog.stdlib.ProcessorFormatter` — it wraps the processor chain as a stdlib `Formatter`. This is documented and supported but adds conceptual overhead and a config split between `structlog.configure()` and Django's `LOGGING` dict.
229
+
230
+ ### 3. Significant migration surface
231
+
232
+ Every `logger.audit(msg, extra=payload)` call across `signals.py`, `middleware.py`, `protocols.py`, and `utils.py` must change. The custom handlers (`AuditLogHandler`, `APILogHandler`) extract fields from `LogRecord.__dict__` populated by `extra=` — that extraction logic must be rewritten or the handlers retired.
233
+
234
+ ### 4. Existing formatters become redundant
235
+
236
+ `AuditFormatter`, `APIFormatter`, `LoginFormatter`, `AppFormatter` (~200 lines in `formatters.py`) produce the same JSON that structlog's `JSONRenderer` would generate. They become dead code — a clean break, but a breaking change for any downstream project that subclassed them.
237
+
238
+ ### 5. Added dependency
239
+
240
+ `structlog` is a mandatory new dependency for a library package. Not all consumers will have it. A clean opt-in design (e.g. `pip install django-activity-audit[structlog]`) is needed.
241
+
242
+ ---
243
+
244
+ ## Summary: Pros vs Cons
245
+
246
+ | | Structlog | Current stdlib |
247
+ |--|-----------|----------------|
248
+ | Context propagation | `bind_contextvars` — automatic, async-safe | `asgiref.local.Local()` — manual, leak-prone |
249
+ | File/line in logs | `CallsiteParameterAdder` — zero call-site code | Not captured in audit formatter |
250
+ | Payload construction | Keyword args on log call | Manual dict + `extra={}` |
251
+ | Formatter logic | Composable processor list | ~200 lines across 4 classes |
252
+ | Test assertions | `capture_logs()` → typed dicts | String matching / mock handlers |
253
+ | Performance | Lazy — skipped if level filtered | Dict always constructed |
254
+ | Custom log levels | Requires `BoundLogger` wrapper | Monkey-patched, works today |
255
+ | Handler compatibility | Needs `ProcessorFormatter` bridge | Works natively |
256
+ | Migration cost | High — all call sites change | Zero |
257
+ | Dependency | New required dep | None |
258
+
259
+ ---
260
+
261
+ ## Recommended Migration Strategy
262
+
263
+ ### Phase 1 — Hybrid: structlog processors, stdlib handlers (low risk)
264
+
265
+ Keep all existing handlers and Django `LOGGING` config. Replace the formatters with `structlog.stdlib.ProcessorFormatter`:
266
+
267
+ ```python
268
+ # formatters.py replacement
269
+ import structlog
270
+ from structlog.stdlib import ProcessorFormatter
271
+
272
+ renderer = ProcessorFormatter(
273
+ processor=structlog.processors.JSONRenderer(),
274
+ foreign_pre_chain=[
275
+ structlog.contextvars.merge_contextvars,
276
+ structlog.processors.add_log_level,
277
+ structlog.processors.TimeStamper(fmt="iso"),
278
+ structlog.processors.CallsiteParameterAdder([
279
+ structlog.processors.CallsiteParameter.FILENAME,
280
+ structlog.processors.CallsiteParameter.LINENO,
281
+ structlog.processors.CallsiteParameter.FUNC_NAME,
282
+ ]),
283
+ ],
284
+ )
285
+ ```
286
+
287
+ Wire it into the existing Django `LOGGING` dict:
288
+
289
+ ```python
290
+ LOGGING = {
291
+ "formatters": {
292
+ "audit_json": {"()": lambda: renderer},
293
+ },
294
+ "handlers": {
295
+ "audit_file": {
296
+ **get_async_audit_handler(filename="audit_logs/audit.log"),
297
+ "formatter": "audit_json",
298
+ },
299
+ ...
300
+ },
301
+ }
302
+ ```
303
+
304
+ This unlocks `CallsiteParameterAdder` and cleaner processor composition with zero changes to handlers or call sites.
305
+
306
+ ### Phase 2 — Context migration
307
+
308
+ Replace `asgiref.local.Local()` thread-locals in `middleware.py:8–82` with `structlog.contextvars`:
309
+
310
+ ```python
311
+ # middleware.py
312
+
313
+ import structlog.contextvars as ctx
314
+
315
+ class AuditLoggingMiddleware:
316
+ def __call__(self, request):
317
+ request_id = str(uuid.uuid4())
318
+ ctx.clear_contextvars()
319
+ ctx.bind_contextvars(request_id=request_id)
320
+ try:
321
+ response = self.get_response(request)
322
+ user_id, user_info = _extract_user(request)
323
+ ctx.bind_contextvars(user_id=user_id, user_info=user_info)
324
+ ...
325
+ finally:
326
+ ctx.clear_contextvars()
327
+ return response
328
+ ```
329
+
330
+ Remove `get_request_id()`, `get_user_details()`, `set_current_request()`, `clear_request()` from `push_log()` — the processor chain picks them up automatically via `merge_contextvars`.
331
+
332
+ This also fixes the thread-local leak described in `improvements.md` (issue #7) as a side effect.
333
+
334
+ ### Phase 3 — Call-site cleanup
335
+
336
+ Refactor `push_log()` to use structlog native calls, retire `extra={}` pattern, update tests to use `capture_logs()`.
337
+
338
+ ---
339
+
340
+ ## File/Line Info in Signal Handlers — Implementation Detail
341
+
342
+ Add `CallsiteParameterAdder` to the processor chain (Phase 1). The processor requires a `_record` key to be present, which `ProcessorFormatter` injects automatically when bridging from stdlib.
343
+
344
+ Every log entry emitted from `push_log()` (signals.py:146) will include:
345
+
346
+ ```json
347
+ {
348
+ "filename": "signals.py",
349
+ "lineno": 146,
350
+ "func_name": "push_log"
351
+ }
352
+ ```
353
+
354
+ To also capture the Django model file that owns the `.save()` call, add a lightweight stack inspector:
355
+
356
+ ```python
357
+ import inspect
358
+
359
+ def _get_save_callsite() -> dict:
360
+ for frame_info in inspect.stack():
361
+ if frame_info.filename.endswith("signals.py"):
362
+ continue
363
+ if frame_info.function in ("save", "bulk_create", "bulk_update"):
364
+ continue
365
+ return {
366
+ "trigger_file": frame_info.filename,
367
+ "trigger_line": frame_info.lineno,
368
+ "trigger_func": frame_info.function,
369
+ }
370
+ return {}
371
+ ```
372
+
373
+ Pass the result as `extra` in `push_log()`. Use sparingly — `inspect.stack()` is expensive and should be guarded behind a debug flag or sampled.
374
+
375
+ ---
376
+
377
+ ## Output Log Shape (Post-Migration)
378
+
379
+ ```json
380
+ {
381
+ "timestamp": "2026-06-16T10:22:45.123Z",
382
+ "level": "audit",
383
+ "logger": "audit.model",
384
+ "event": "CREATE event for Author (id: abc123)",
385
+ "filename": "signals.py",
386
+ "lineno": 146,
387
+ "func_name": "push_log",
388
+ "request_id": "f3c9a1b2-0001-4abc-beef-deadbeef0001",
389
+ "user_id": "cae8ffb4-ba52-409c-9a6f-e10362bfaf97",
390
+ "user_info": {
391
+ "email": "user@example.com",
392
+ "first_name": "Ada",
393
+ "last_name": "Lovelace"
394
+ },
395
+ "model": "Author",
396
+ "event_type": "CREATE",
397
+ "instance_id": "9f1b2c3d-...",
398
+ "instance_repr": { "name": "Ada Lovelace", "bio": "Mathematician" },
399
+ "extra": {}
400
+ }
401
+ ```
402
+
403
+ All fields from the current `AuditFormatter` output are preserved. New fields: `filename`, `lineno`, `func_name`. The `request_id` / `user_id` / `user_info` fields are no longer passed manually — they come from the processor chain.
404
+
405
+ ---
406
+
407
+ ## Dependency
408
+
409
+ ```toml
410
+ # pyproject.toml
411
+ [project.optional-dependencies]
412
+ structlog = ["structlog>=24.0"]
413
+ ```
414
+
415
+ ```bash
416
+ pip install django-activity-audit[structlog]
417
+ ```
418
+
419
+ The core package remains dependency-free. Structlog features activate only when the optional extra is installed and `structlog.configure()` is called by the host application.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "django-activity-audit"
3
- version = "1.3.0.dev17"
3
+ version = "1.3.0.dev19"
4
4
  description = "A Django package for easy CRUD operation logging and container logs"
5
5
  authors = [
6
6
  { name = "Shreeshan", email = "shreeshan256@gmail.com" }
@@ -50,7 +50,9 @@ test = [
50
50
  "pytest-django>=4.0.0",
51
51
  "pytest-cov>=4.0.0",
52
52
  "djangorestframework>=3.14.0",
53
+ "structlog>=24.0",
53
54
  ]
55
+ structlog = ["structlog>=24.0"]
54
56
 
55
57
  [build-system]
56
58
  requires = ["hatchling"]
@@ -1,22 +0,0 @@
1
- from django.apps import AppConfig
2
-
3
-
4
- class AuditLoggingConfig(AppConfig):
5
- default_auto_field = "django.db.models.BigAutoField"
6
- name = "activity_audit"
7
- verbose_name = "Django Activity Audit"
8
-
9
- def ready(self):
10
- # Import and register custom log levels
11
- from . import logger_levels
12
-
13
- # Force registration of custom levels
14
- logger_levels.AUDIT
15
- logger_levels.API
16
- logger_levels.LOGIN
17
-
18
- # Populate UNREGISTERED_CLASSES (requires app registry to be ready)
19
- from . import unregistered # noqa
20
-
21
- # Initialize signals
22
- from . import signals # noqa