Metadata-Version: 2.4
Name: xc-logging
Version: 0.1.0
Summary: Structured logging primitives for Xcapit Django applications.
Author: Xcapit
License-Expression: Apache-2.0
Project-URL: Repository, https://gitlab.com/xcapit-foss/xc-logging
Project-URL: Issues, https://gitlab.com/xcapit-foss/xc-logging/-/issues
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: Django<6.1,>=5.2
Requires-Dist: structlog<27,>=26.1
Provides-Extra: model-utils
Requires-Dist: django-model-utils<6,>=5; extra == "model-utils"
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == "dev"
Requires-Dist: pytest<9,>=8.3; extra == "dev"
Requires-Dist: pytest-cov<8,>=7.1; extra == "dev"
Dynamic: license-file

# xc-logging

Structured logging primitives for Xcapit Django applications.

## Requirements

- Python 3.11 or newer
- Django 5.2 or newer, below Django 6.1
- structlog 26.x
- django-model-utils 5.x (optional, for transactional logs with `FieldTracker`)

## Installation

```bash
python -m pip install xc-logging
```

To include the optional `FieldTracker` integration:

```bash
python -m pip install 'xc-logging[model-utils]'
```

## Django configuration

Add the logs context middleware before Django's authentication middleware:

```python
MIDDLEWARE = [
    "xc_logging.LogsContextMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
]
```

By default, the middleware records `REMOTE_ADDR` and ignores
`X-Forwarded-For`, because clients can spoof that header. Trust it only when
the application is behind a controlled reverse proxy that overwrites the
incoming header:

```python
LOG_TRUST_X_FORWARDED_FOR = True
```

When `HmacSha256HashMethod` is constructed without an explicit pepper, define:

```python
LOG_MASKING_PEPPER = "a-secret-value"
```

The consuming application remains responsible for configuring structlog and its handlers.

## Usage

```python
from xc_logging import DefaultLog, LogType

DefaultLog(LogType.SECURITY, "auth.login", user_id=42).info()
DefaultLog(LogType.AUDIT, "audit.read", user_id=42).info()
```

```python
from xc_logging import TransactionalLog

TransactionalLog(
    "purchase.update",
    "purchases.models.PurchaseOrderModel",
    1,
    {"price": 5},
    {"price": 10},
).info()
```

```python
from xc_logging import BlockchainLog

BlockchainLog(
    "token.mint",
    "0xabc",
    123,
    "0xcontract",
    "lacnet-mainnet",
).info()
```

```python
from xc_logging import HmacSha256HashMethod

digest = HmacSha256HashMethod().encoded("sensitive-value")
```

## Transactional logs with Django signals

Transactional logs can be generated automatically when Django models are updated. The
integration uses `FieldTracker` to capture changed fields, masks sensitive values before
they leave the request process, and emits the log only after the database transaction is
committed successfully.

This integration intentionally lives in an `audit` app in the consuming project. The
application owns signal registration and decides which models are audited; `xc-logging`
provides the log, hashing, and masking primitives.

### 1. Install the dependencies

Install this package with the `model-utils` extra in the consuming project:

```bash
python -m pip install 'xc-logging[model-utils]'
```

For an editable installation from a sibling checkout, use:

```bash
python -m pip install -e '../xc-logging[model-utils]'
```

The extra installs the compatible `django-model-utils` version automatically. Record the
extra in the consuming project's dependency file so deployments install the same feature
set.

### 2. Configure the masking pepper

Define a secret pepper outside the source code and expose it through Django settings:

```bash
export LOG_MASKING_PEPPER='replace-with-a-secret-value'
```

```python
import os

LOG_MASKING_PEPPER = os.getenv("LOG_MASKING_PEPPER")
```

The same pepper produces the same digest, which allows events to be correlated without
logging the original value. Rotating it changes future digests and therefore breaks that
correlation with historical events.

### 3. Create and register the audit app

Create a Django app in the consuming project:

```bash
python manage.py startapp audit
```

Register its explicit app config:

```python
INSTALLED_APPS = [
    "audit.apps.AuditConfig",
]
```

Configure `audit/apps.py` so the receivers are loaded during application startup and the
application fails fast when the pepper is missing:

```python
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured


class AuditConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "audit"

    def ready(self):
        import audit.signals

        if not settings.LOG_MASKING_PEPPER:
            raise ImproperlyConfigured(
                "LOG_MASKING_PEPPER is required to mask PII in transactional logs"
            )
```

The `audit` app does not need database models or migrations for this integration.

### 4. Mark the models to audit

Add a `FieldTracker` to every model whose updates should produce transactional logs.
Declare `SENSITIVE_FIELDS` with the exact Django field names that must never appear in
plain text:

```python
from django.db import models
from model_utils import FieldTracker


class CustomerModel(models.Model):
    tracker = FieldTracker()
    SENSITIVE_FIELDS = ("email", "document_number")

    email = models.EmailField()
    document_number = models.CharField(max_length=50)
    status = models.CharField(max_length=20)
```

`SENSITIVE_FIELDS` is optional. A model with `tracker` but without that tuple is audited
without masking, so it must not contain changed fields that expose personal or secret
information. `None` values are preserved; every other sensitive value is converted to
text and hashed with HMAC-SHA256.

### 5. Create the signals

Create `audit/signals.py`:

```python
from django.db import transaction
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver

from xc_logging import HmacSha256HashMethod, MaskedChanges, TransactionalLog


@receiver(pre_save)
def pre_save_handler(sender, instance, **kwargs):
    if not instance.pk or not hasattr(instance, "tracker"):
        return

    changed_fields = instance.tracker.changed()
    if not changed_fields:
        return

    changes = {"before": {}, "after": {}}
    for field, before_value in changed_fields.items():
        changes["before"][field] = before_value
        changes["after"][field] = getattr(instance, field)

    instance._changes = MaskedChanges(
        changes,
        getattr(instance, "SENSITIVE_FIELDS", ()),
        HmacSha256HashMethod(),
    ).value()


@receiver(post_save)
def queue_log_on_success(sender, instance, **kwargs):
    if not hasattr(instance, "_changes"):
        return

    changes = instance._changes
    model = instance
    transaction.on_commit(lambda: _log_changes(model, changes))
    delattr(instance, "_changes")


def _log_changes(model, changes):
    model_class = model.__class__
    TransactionalLog(
        f"{model._meta.app_config.name}.{model_class.__name__}",
        f"{model_class.__module__}.{model_class.__name__}",
        model.pk,
        changes["before"],
        changes["after"],
    ).info()
```

The emitted event contains the app and model names, the object primary key, and only the
fields reported as changed by `FieldTracker`. Sensitive before/after values are already
masked when they reach `TransactionalLog`.

### Behavior and limitations

- Only updates to existing instances are logged. Creates are ignored because there is no
  previous state.
- The log is queued with `transaction.on_commit`; a rolled-back transaction does not emit
  an event.
- Use model `save()` for audited changes. `QuerySet.update()`, `bulk_update()`, and other
  bulk operations bypass Django's `pre_save` and `post_save` signals.
- Keep `SENSITIVE_FIELDS` synchronized with model fields. An omitted sensitive field can
  be logged in plain text.
- This mechanism produces transactional change logs. Explicit audit events continue to
  use `DefaultLog(LogType.AUDIT, ...)`; there is no separate `AuditLog` class.

## Development

Install an editable checkout with the development dependencies:

```bash
python -m pip install -e '.[dev]'
python -m pytest
python -m build
```

To use the optional `FieldTracker` integration from a sibling checkout:

```bash
python -m pip install -e '.[model-utils]'
```

To test the generated wheel locally:

```bash
python -m pip install dist/xc_logging-0.1.0-py3-none-any.whl
```

The test command measures line and branch coverage for `xc_logging`, displays missing
lines, and fails if total coverage is below 90%. Test modules are excluded from the
measurement.

Generate an HTML report when a navigable line-by-line view is needed:

```bash
python -m pytest --cov-report=html
```

Open `htmlcov/index.html` in a browser to inspect the report.

## License

Copyright 2026 Xcapit. Licensed under the Apache License, Version 2.0. See
[LICENSE](LICENSE) and [NOTICE](NOTICE) for details.
