app.case_logging

Case-scoped application logging helpers.

Routes Python logging records to per-case log files so that each forensic case maintains its own logs/application.log. This is separate from the forensic audit trail (audit.jsonl) and is intended for developer-facing diagnostic output.

The mechanism works by attaching a logging.Handler to the root logger for every active case. A ~contextvars.ContextVar tracks which case ID is active on the current thread / coroutine so that log records are routed to the correct file without requiring callers to pass case references explicitly.

Typical usage::

register_case_log_handler(case_id, case_dir)
with case_log_context(case_id):
    logging.getLogger("app").info("Parsing started")
Attributes:
  • CASE_LOGS_DIRNAME: Subdirectory name for case log files.
  • CASE_LOG_FILENAME: Name of the per-case application log file.
  • CASE_LOG_FORMAT: logging format string for case log entries.
  1"""Case-scoped application logging helpers.
  2
  3Routes Python :mod:`logging` records to per-case log files so that each
  4forensic case maintains its own ``logs/application.log``.  This is separate
  5from the forensic audit trail (``audit.jsonl``) and is intended for
  6developer-facing diagnostic output.
  7
  8The mechanism works by attaching a :class:`logging.Handler` to the root
  9logger for every active case.  A :class:`~contextvars.ContextVar` tracks
 10which case ID is active on the current thread / coroutine so that log
 11records are routed to the correct file without requiring callers to pass
 12case references explicitly.
 13
 14Typical usage::
 15
 16    register_case_log_handler(case_id, case_dir)
 17    with case_log_context(case_id):
 18        logging.getLogger("app").info("Parsing started")
 19
 20Attributes:
 21    CASE_LOGS_DIRNAME: Subdirectory name for case log files.
 22    CASE_LOG_FILENAME: Name of the per-case application log file.
 23    CASE_LOG_FORMAT: :mod:`logging` format string for case log entries.
 24"""
 25
 26from __future__ import annotations
 27
 28from contextlib import contextmanager
 29from contextvars import ContextVar, Token
 30import logging
 31from pathlib import Path
 32import threading
 33from typing import Iterator
 34
 35__all__ = [
 36    "push_case_log_context",
 37    "pop_case_log_context",
 38    "case_log_context",
 39    "register_case_log_handler",
 40    "unregister_case_log_handler",
 41    "unregister_all_case_log_handlers",
 42]
 43
 44CASE_LOGS_DIRNAME = "logs"
 45CASE_LOG_FILENAME = "application.log"
 46CASE_LOG_FORMAT = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
 47
 48_ACTIVE_CASE_ID: ContextVar[str | None] = ContextVar("aift_active_case_id", default=None)
 49_HANDLER_LOCK = threading.RLock()
 50_CASE_HANDLERS: dict[str, logging.Handler] = {}
 51
 52
 53class _CasePathLogHandler(logging.Handler):
 54    """Logging handler that appends formatted records to a file path.
 55
 56    The file is opened and closed for each record to avoid holding file
 57    descriptors open across long-lived operations.
 58
 59    Args:
 60        log_path: Destination file path for log output.
 61    """
 62
 63    def __init__(self, log_path: Path) -> None:
 64        super().__init__()
 65        self.log_path = Path(log_path)
 66
 67    def emit(self, record: logging.LogRecord) -> None:
 68        """Write a single formatted log record to :attr:`log_path`."""
 69        try:
 70            rendered = self.format(record)
 71            self.log_path.parent.mkdir(parents=True, exist_ok=True)
 72            with self.log_path.open("a", encoding="utf-8") as stream:
 73                stream.write(f"{rendered}\n")
 74        except OSError:
 75            return
 76
 77
 78class _CaseLogFilter(logging.Filter):
 79    """Logging filter that passes only records matching a specific case ID.
 80
 81    If the record does not carry a ``case_id`` attribute, the filter
 82    falls back to the context variable set via :func:`push_case_log_context`.
 83
 84    Args:
 85        case_id: The case identifier this filter accepts.
 86    """
 87
 88    def __init__(self, case_id: str) -> None:
 89        super().__init__()
 90        self.case_id = case_id
 91
 92    def filter(self, record: logging.LogRecord) -> bool:
 93        """Return *True* when the record belongs to this filter's case."""
 94        record_case_id = getattr(record, "case_id", None)
 95        if not record_case_id:
 96            record_case_id = _ACTIVE_CASE_ID.get()
 97            if record_case_id:
 98                setattr(record, "case_id", record_case_id)
 99        return str(record_case_id or "").strip() == self.case_id
100
101
102def push_case_log_context(case_id: str | None) -> Token[str | None]:
103    """Bind a case ID to the current execution context for log routing.
104
105    Args:
106        case_id: Case identifier to set, or *None* to clear.
107
108    Returns:
109        A :class:`~contextvars.Token` that can be passed to
110        :func:`pop_case_log_context` to restore the previous value.
111    """
112    normalized = str(case_id).strip() if case_id is not None else None
113    return _ACTIVE_CASE_ID.set(normalized or None)
114
115
116def pop_case_log_context(token: Token[str | None]) -> None:
117    """Restore the previous case log context.
118
119    Args:
120        token: Token returned by a prior :func:`push_case_log_context` call.
121    """
122    _ACTIVE_CASE_ID.reset(token)
123
124
125@contextmanager
126def case_log_context(case_id: str | None) -> Iterator[None]:
127    """Context manager that binds *case_id* for logs emitted within its scope.
128
129    Args:
130        case_id: Case identifier to bind, or *None* to clear.
131
132    Yields:
133        Nothing.  The context variable is restored on exit.
134    """
135    token = push_case_log_context(case_id)
136    try:
137        yield
138    finally:
139        pop_case_log_context(token)
140
141
142def register_case_log_handler(case_id: str, case_dir: str | Path) -> Path:
143    """Attach a per-case file handler to the root logger.
144
145    Creates a ``logs/application.log`` file inside *case_dir* and installs
146    a :class:`_CasePathLogHandler` with a :class:`_CaseLogFilter` so that
147    only records matching *case_id* are written there.  Duplicate
148    registrations for the same case are silently ignored.
149
150    Args:
151        case_id: Non-empty case identifier string.
152        case_dir: Path to the case directory where logs will be stored.
153
154    Returns:
155        The :class:`~pathlib.Path` to the created log file.
156
157    Raises:
158        ValueError: If *case_id* is empty or whitespace-only.
159    """
160    normalized_case_id = str(case_id).strip()
161    if not normalized_case_id:
162        raise ValueError("case_id must be a non-empty string.")
163
164    root_logger = logging.getLogger()
165    app_logger = logging.getLogger("app")
166    logs_dir = Path(case_dir) / CASE_LOGS_DIRNAME
167    logs_dir.mkdir(parents=True, exist_ok=True)
168    log_path = logs_dir / CASE_LOG_FILENAME
169
170    with _HANDLER_LOCK:
171        existing = _CASE_HANDLERS.get(normalized_case_id)
172        if existing is not None:
173            return log_path
174
175        handler = _CasePathLogHandler(log_path)
176        handler.setLevel(logging.INFO)
177        handler.setFormatter(logging.Formatter(CASE_LOG_FORMAT))
178        handler.addFilter(_CaseLogFilter(normalized_case_id))
179        root_logger.addHandler(handler)
180        _CASE_HANDLERS[normalized_case_id] = handler
181
182        if app_logger.level == logging.NOTSET or app_logger.level > logging.INFO:
183            app_logger.setLevel(logging.INFO)
184
185    return log_path
186
187
188def unregister_case_log_handler(case_id: str) -> None:
189    """Detach and close the file handler for *case_id* if it exists.
190
191    Args:
192        case_id: Case identifier whose handler should be removed.
193    """
194    normalized_case_id = str(case_id).strip()
195    if not normalized_case_id:
196        return
197
198    with _HANDLER_LOCK:
199        handler = _CASE_HANDLERS.pop(normalized_case_id, None)
200        if handler is None:
201            return
202        root_logger = logging.getLogger()
203        root_logger.removeHandler(handler)
204        handler.close()
205
206
207def unregister_all_case_log_handlers() -> None:
208    """Detach and close all case file handlers."""
209    with _HANDLER_LOCK:
210        handlers = list(_CASE_HANDLERS.values())
211        _CASE_HANDLERS.clear()
212        root_logger = logging.getLogger()
213        for handler in handlers:
214            root_logger.removeHandler(handler)
215            handler.close()
def push_case_log_context(case_id: str | None) -> _contextvars.Token[str | None]:
103def push_case_log_context(case_id: str | None) -> Token[str | None]:
104    """Bind a case ID to the current execution context for log routing.
105
106    Args:
107        case_id: Case identifier to set, or *None* to clear.
108
109    Returns:
110        A :class:`~contextvars.Token` that can be passed to
111        :func:`pop_case_log_context` to restore the previous value.
112    """
113    normalized = str(case_id).strip() if case_id is not None else None
114    return _ACTIVE_CASE_ID.set(normalized or None)

Bind a case ID to the current execution context for log routing.

Arguments:
  • case_id: Case identifier to set, or None to clear.
Returns:

A ~contextvars.Token that can be passed to pop_case_log_context() to restore the previous value.

def pop_case_log_context(token: _contextvars.Token[str | None]) -> None:
117def pop_case_log_context(token: Token[str | None]) -> None:
118    """Restore the previous case log context.
119
120    Args:
121        token: Token returned by a prior :func:`push_case_log_context` call.
122    """
123    _ACTIVE_CASE_ID.reset(token)

Restore the previous case log context.

Arguments:
@contextmanager
def case_log_context(case_id: str | None) -> Iterator[NoneType]:
126@contextmanager
127def case_log_context(case_id: str | None) -> Iterator[None]:
128    """Context manager that binds *case_id* for logs emitted within its scope.
129
130    Args:
131        case_id: Case identifier to bind, or *None* to clear.
132
133    Yields:
134        Nothing.  The context variable is restored on exit.
135    """
136    token = push_case_log_context(case_id)
137    try:
138        yield
139    finally:
140        pop_case_log_context(token)

Context manager that binds case_id for logs emitted within its scope.

Arguments:
  • case_id: Case identifier to bind, or None to clear.
Yields:

Nothing. The context variable is restored on exit.

def register_case_log_handler(case_id: str, case_dir: str | pathlib.Path) -> pathlib.Path:
143def register_case_log_handler(case_id: str, case_dir: str | Path) -> Path:
144    """Attach a per-case file handler to the root logger.
145
146    Creates a ``logs/application.log`` file inside *case_dir* and installs
147    a :class:`_CasePathLogHandler` with a :class:`_CaseLogFilter` so that
148    only records matching *case_id* are written there.  Duplicate
149    registrations for the same case are silently ignored.
150
151    Args:
152        case_id: Non-empty case identifier string.
153        case_dir: Path to the case directory where logs will be stored.
154
155    Returns:
156        The :class:`~pathlib.Path` to the created log file.
157
158    Raises:
159        ValueError: If *case_id* is empty or whitespace-only.
160    """
161    normalized_case_id = str(case_id).strip()
162    if not normalized_case_id:
163        raise ValueError("case_id must be a non-empty string.")
164
165    root_logger = logging.getLogger()
166    app_logger = logging.getLogger("app")
167    logs_dir = Path(case_dir) / CASE_LOGS_DIRNAME
168    logs_dir.mkdir(parents=True, exist_ok=True)
169    log_path = logs_dir / CASE_LOG_FILENAME
170
171    with _HANDLER_LOCK:
172        existing = _CASE_HANDLERS.get(normalized_case_id)
173        if existing is not None:
174            return log_path
175
176        handler = _CasePathLogHandler(log_path)
177        handler.setLevel(logging.INFO)
178        handler.setFormatter(logging.Formatter(CASE_LOG_FORMAT))
179        handler.addFilter(_CaseLogFilter(normalized_case_id))
180        root_logger.addHandler(handler)
181        _CASE_HANDLERS[normalized_case_id] = handler
182
183        if app_logger.level == logging.NOTSET or app_logger.level > logging.INFO:
184            app_logger.setLevel(logging.INFO)
185
186    return log_path

Attach a per-case file handler to the root logger.

Creates a logs/application.log file inside case_dir and installs a _CasePathLogHandler with a _CaseLogFilter so that only records matching case_id are written there. Duplicate registrations for the same case are silently ignored.

Arguments:
  • case_id: Non-empty case identifier string.
  • case_dir: Path to the case directory where logs will be stored.
Returns:

The ~pathlib.Path to the created log file.

Raises:
  • ValueError: If case_id is empty or whitespace-only.
def unregister_case_log_handler(case_id: str) -> None:
189def unregister_case_log_handler(case_id: str) -> None:
190    """Detach and close the file handler for *case_id* if it exists.
191
192    Args:
193        case_id: Case identifier whose handler should be removed.
194    """
195    normalized_case_id = str(case_id).strip()
196    if not normalized_case_id:
197        return
198
199    with _HANDLER_LOCK:
200        handler = _CASE_HANDLERS.pop(normalized_case_id, None)
201        if handler is None:
202            return
203        root_logger = logging.getLogger()
204        root_logger.removeHandler(handler)
205        handler.close()

Detach and close the file handler for case_id if it exists.

Arguments:
  • case_id: Case identifier whose handler should be removed.
def unregister_all_case_log_handlers() -> None:
208def unregister_all_case_log_handlers() -> None:
209    """Detach and close all case file handlers."""
210    with _HANDLER_LOCK:
211        handlers = list(_CASE_HANDLERS.values())
212        _CASE_HANDLERS.clear()
213        root_logger = logging.getLogger()
214        for handler in handlers:
215            root_logger.removeHandler(handler)
216            handler.close()

Detach and close all case file handlers.