Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

django-forge-log

CI PyPI License: MIT Python 3.12+

English | Français

Release candidate. django-forge-log is at 1.0.0rc4: the API is considered frozen but has not yet been exercised by real-world usage outside of this repository. Feedback (issues, use cases, bugs) is welcome before the final 1.0.0 is tagged — see RELEASING.md.

A lightweight, automatic audit trail — Who, What, When, Where, and the before/after Diff — for Django views (FBV, CBV, DRF ViewSets) and the Admin, stored in a single central JSON table.

The problem

The native LogEntry from django.contrib.admin only tracks what goes through the Admin. As soon as a mutation goes through a DRF API, a plain view, or a script, nothing gets logged anymore. django-simple-history/ django-reversion solve this by duplicating a table per tracked model — heavy, and it doesn't explain who made the change or from where. django-forge-log aims for a single, compact audit trail, with strict typing on the diff.

Installation

The core (middleware, decorator, diff engine, Admin) only depends on Django and Pydantic. The DRF, django-signals-all, and Celery integrations are separate extras to enable, combined as needed:

# Core only
uv add django-forge-log

# With the DRF integration (forge_log.drf.AuditViewSetMixin)
uv add "django-forge-log[drf]"

# With the django-signals-all bulk integration (bulk_create/bulk_update/.update())
uv add "django-forge-log[signals]"

# With the Celery write backend (WRITE_BACKEND="celery")
uv add "django-forge-log[celery]"

# Several extras at once
uv add "django-forge-log[drf,signals,celery]"

With pip, same combinations:

pip install django-forge-log
pip install "django-forge-log[drf,signals,celery]"

Since a release candidate is not a final version, PyPI does not install it by default with pip install django-forge-log — use --pre or pin the exact version until 1.0.0 is tagged:

uv add "django-forge-log==1.0.0rc4"
pip install "django-forge-log==1.0.0rc4"
# settings.py
INSTALLED_APPS = [
    ...,
    "django.contrib.contenttypes",
    "forge_log",
]

MIDDLEWARE = [
    ...,
    "forge_log.middleware.RequestContextMiddleware",
]
python manage.py migrate forge_log

Quick start

Generic view (FBV/CBV)

from forge_log.decorators import track_action
from .models import Article


@track_action(Article)
def update_article(request, pk):
    article = Article.objects.get(pk=pk)
    article.status = "published"
    article.save()
    return HttpResponse(...)

Without configuration, the tracked instance is looked up via the PK present in the view kwargs (pk, or the model's primary key field name) — the common /resource/<pk>/ case. For a creation (no PK in the URL before the view runs), provide get_instance explicitly, or prefer the DRF mixin below.

async def views are detected automatically (get_instance, the diff computation, and the write then switch to sync_to_async):

@track_action(Article)
async def update_article(request, pk):
    article = await Article.objects.aget(pk=pk)
    article.status = "published"
    await article.asave()
    return JsonResponse(...)

With WRITE_BACKEND="asyncio", this path runs on the executor thread rather than on the event loop: the write then falls back to AsyncTaskWriter's synchronous mode rather than asyncio.create_task() — still correct, but without the expected performance gain. Prefer thread or celery for high-traffic tracked async views.

DRF ViewSet

from rest_framework.viewsets import ModelViewSet
from forge_log.drf import AuditViewSetMixin


class ArticleViewSet(AuditViewSetMixin, ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

Natively captures create/update/partial_update/destroy, including creations (unlike track_action, which needs a PK in the URL). Requires the django-forge-log[drf] extra.

Admin

from django.contrib import admin
from forge_log.admin import AuditModelAdminMixin


@admin.register(Article)
class ArticleAdmin(AuditModelAdminMixin, admin.ModelAdmin):
    pass

The ActionLog table itself is browsable (read-only) in the Admin via forge_log.admin.ActionLogAdmin.

Bulk operations (bulk_create, bulk_update, .update())

These operations bypass Model.save() and none of the three integrations above can see them from the view. By wiring up django-signals-all (django-forge-log[signals] extra), bulk mutations emitted through its BulkSignalManager are logged automatically, with no extra code:

# models.py
from django.db import models
from django_signals_all.orm.manager import BulkSignalManager


class Article(models.Model):
    ...
    objects = BulkSignalManager()
# settings.py
INSTALLED_APPS = [..., "django_signals_all", "forge_log"]

.filter(...).update(...) does not load the modified instances: a single aggregated entry is logged (action="BULK_UPDATE", object_id=None), with the list of impacted PKs in metadata. bulk_create() and Manager.bulk_update(), on the other hand, log one entry per instance.

The same masking and exclusion rules as the diff apply to these paths (EXCLUDED_FIELDS, MASKED_FIELDS, ForgeLogMeta): in the aggregated update_kwargs, an excluded field is omitted and a masked field appears as {"masked": true}. updated_ids is capped at 1000 identifiers (updated_ids_truncated; count stays exact).

Object history

ActionLog.objects.for_object(article)          # QuerySet, most recent first
ActionLog.objects.for_object(article).filter(action="UPDATE")

Or directly from the instance, by adding the mixin to the tracked model:

from forge_log.relations import ForgeLogRelationMixin


class Article(ForgeLogRelationMixin, models.Model):
    ...


article.forge_log_entries.all()  # equivalent to ActionLog.objects.for_object(article)

ForgeLogRelationMixin is an abstract class (no concrete field, so no migration required), deliberately not a GenericRelation: GenericRelation forces on_delete=CASCADE in a way that cannot be configured on the Django side, which would delete an object's entire audit history at the exact moment it is deleted — the opposite of what an audit trail must guarantee.

Asynchronous writes (WRITE_BACKEND)

Writing to ActionLog has a cost. Five backends, selectable per project or per test:

Backend Behavior Durability Cost on the request
sync Writes immediately Maximal, but logs even a rolled-back transaction High
on_commit transaction.on_commit() Never logged on rollback High (always before the response)
thread (default) In-memory queue + daemon thread, bulk_create in batches Loss window (~200 ms) if the process is killed Near zero (queue.put_nowait)
asyncio asyncio.create_task() (async def views) Same as thread; requires an active event loop Near zero
celery Full dispatch via a Celery task Robust (persisted in the broker) Near zero
FORGE_LOG = {
    "WRITE_BACKEND": "thread",  # "sync" | "on_commit" | "thread" | "asyncio" | "celery"
}

celery requires the django-forge-log[celery] extra and a broker already configured on the project side. The forge_log.write_entry task is registered by forge_log.tasks (imported when the app is ready and found by app.autodiscover_tasks()): no manual import is needed in the worker.

A write error never fails the request: it is logged on the forge_log logger (set RAISE_ON_ERROR to propagate it). In the thread backend a failed batch is retried entry by entry, the thread survives errors, is restarted after a fork, and is drained at interpreter exit; a full queue is logged.

Security and PII

The diff can expose sensitive data if left unconfigured:

FORGE_LOG = {
    # Fields never logged (before *and* after), by regex pattern.
    "EXCLUDED_FIELDS": [r".*secret.*", r".*token.*", r".*_key$", r"credit_card", r"ssn"],
    # Fields logged as "changed" without exposing the values:
    # {"password": {"masked": true}} rather than {"before": ..., "after": ...}.
    "MASKED_FIELDS": ["password"],
}

These settings can also be set per model, which takes precedence over the global config:

class Article(models.Model):
    ...
    class ForgeLogMeta:
        excluded_fields = ["internal_note"]
        masked_fields = []

FileField/ImageField never log binary content, only the path (.name).

Text written to the journal is sanitized: control characters (line breaks, NUL) are replaced by a space in the short columns (user_repr, endpoint, object_repr, user_agent...), NUL is removed from changes/metadata, strings there are cut at MAX_VALUE_LENGTH (marker [truncated]), and user_agent is cut at 1024 characters.

Client IP. X-Forwarded-For is fully controlled by the client, so it is ignored by default and ip is REMOTE_ADDR. Behind N trusted proxies, set TRUSTED_PROXY_COUNT = N: the IP is then the N-th entry from the right.

Immutability of the journal

ActionLog is append-only at the ORM level:

  • save() on an existing entry, instance.delete(), and the queryset update(), delete() and bulk_update() raise forge_log.immutability.ImmutableLogError;
  • creating entries (create(), bulk_create()) is unaffected;
  • the only way through is an explicit door, used by forgelog_purge:
from forge_log.immutability import allow_mutation

with allow_mutation():
    ActionLog.objects.filter(timestamp__lt=cutoff).delete()

The door is scoped to the with block and to the current thread or asyncio task (a ContextVar). Keep it for retention jobs and tests.

This does not protect against direct database access. Raw SQL, the dbshell, a migration or anyone holding UPDATE/DELETE rights on the table bypasses the ORM entirely. For real tamper resistance, enforce immutability in the database as well: revoke UPDATE and DELETE on the table from the application role, or add a trigger that rejects them. The purge job then needs a dedicated role or a temporary lift of the trigger.

User deletion (USER_FK)

ActionLog.user used to be a foreign key with on_delete=SET_NULL, which ties the journal to the user table. Since rc4 the default is:

FORGE_LOG = {
    "USER_FK": "unconstrained",  # default; or "constrained"
}
  • "unconstrained" (default): db_constraint=False, on_delete=DO_NOTHING. Deleting a user never touches the journal and is never blocked by it: user_id and user_repr are kept as they were. entry.user raises DoesNotExist once the user is gone; read user_id/user_repr instead.
  • "constrained": the former behavior (real foreign key, SET_NULL: user_id becomes NULL when the user is deleted, user_repr stays). Choose it only if you rely on referential integrity.

The default favors keeping the audit trace. The value is read when Django loads the models and migrations: set it before running migrate, and restart the application when you change it. To switch after the migration was applied, run python manage.py migrate forge_log 0002 then python manage.py migrate forge_log with the new value.

Upgrading to 1.0.0rc4

  1. python manage.py migrate forge_log applies 0003_alter_actionlog_user (drops the foreign key constraint on user_id; no data change). To keep the former constraint, set FORGE_LOG["USER_FK"] = "constrained" first.
  2. ORM updates and deletions of ActionLog now raise. Wrap legitimate maintenance code in allow_mutation(). forgelog_purge already does.
  3. X-Forwarded-For is no longer trusted by default. Behind a reverse proxy, set FORGE_LOG["TRUSTED_PROXY_COUNT"] to the number of proxies in front of the application, otherwise ip is the proxy address.
  4. Write errors no longer fail the request: they are logged on the forge_log logger. Set FORGE_LOG["RAISE_ON_ERROR"] = True to get the previous behavior.
  5. forgelog_purge now writes one CUSTOM entry describing the purge, and refuses a retention below 1 day.

Retention

python manage.py forgelog_purge --days 90         # deletes entries older than 90 days
python manage.py forgelog_purge --days 90 --dry-run

Or via FORGE_LOG["RETENTION_DAYS"] to avoid passing --days on every call (to wire up to an application cron — forgelog_purge never runs on its own). The retention must be at least 1 day, and each purge leaves one CUSTOM entry (user_repr="forgelog_purge") with the number of deleted entries.

Full configuration

FORGE_LOG = {
    "ENABLED": True,
    "WRITE_BACKEND": "thread",
    "EXCLUDED_FIELDS": [r".*secret.*", r".*token.*", r".*_key$", r"credit_card", r"ssn"],
    "MASKED_FIELDS": ["password"],
    "USER_FK": "unconstrained",       # or "constrained"
    "TRUSTED_PROXY_COUNT": 0,
    "MAX_VALUE_LENGTH": 10_000,
    "RAISE_ON_ERROR": False,
    "RETENTION_DAYS": None,
    "THREAD_FLUSH_INTERVAL": 0.2,     # seconds, "thread" backend
    "THREAD_MAX_BATCH_SIZE": 50,      # "thread" backend
    "THREAD_MAX_QUEUE_SIZE": 10_000,  # "thread" backend
}

Known limitations

  • object_id is a CharField (not a typed FK) to support non-integer PKs (UUID) without a table per model — the same trade-off as django-reversion/django-guardian. A composite index (content_type, object_id) compensates for the lack of a native FK constraint.
  • track_action without get_instance cannot capture a creation (no PK in the URL before the view runs): use forge_log.drf.AuditViewSetMixin for a DRF ViewSet, or provide get_instance explicitly.
  • The immutability guard is applied by the ORM only; it does not stop raw SQL (see Immutability of the journal).
  • thread backend: queued entries are lost if the process is killed before the next flush (~THREAD_FLUSH_INTERVAL). For a strict durability guarantee, use on_commit or celery.
  • The diff only compares the model's concrete fields (_meta.concrete_fields) or the explicit list passed to fields= — not implicit M2M relations.

Development

uv sync --group dev

uv run ruff check src tests
uv run ruff format --check src tests
uv run mypy

# SQLite (default, no external dependency)
uv run pytest --cov=forge_log --cov-report=term-missing

# PostgreSQL and MySQL (requires Docker)
docker compose up -d
FORGE_LOG_TEST_DB=postgres uv run pytest
FORGE_LOG_TEST_DB=mysql uv run pytest

See CONTRIBUTING.md to contribute, CHANGELOG.md for the release history, and RELEASING.md for the publishing process.

License

MIT

Metadata

Release files for django-forge-log 1.0.0rc4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-forge-log 1.0.0rc4
File Size Uploaded Provenance Actions
django_forge_log-1.0.0rc4.tar.gz 126.8 kB With provenance Details for django_forge_log-1.0.0rc4.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for django-forge-log 1.0.0rc4
File Interpreter ABI Platform Actions
django_forge_log-1.0.0rc4-py3-none-any.whl Python 3 none any Details for django_forge_log-1.0.0rc4-py3-none-any.whl

Total release size: 165.8 kB

Release files / django_forge_log-1.0.0rc4.tar.gz

Download URL django_forge_log-1.0.0rc4.tar.gz
Size 126.8 kB
Tags Source
SHA-256 checksum
How to use checksums
0589b3967b2c9841f20c5fdfed372068f7b48f658f2f1f20741f931933168c6d
BLAKE2b-256 checksum
How to use checksums
68b831983a2424b3e40e9fbe432a17c4aca5ad30933a510fb7c716627337d957
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.

Transparency log

Release files / django_forge_log-1.0.0rc4-py3-none-any.whl

Download URL django_forge_log-1.0.0rc4-py3-none-any.whl
Size 38.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
74213c0c081a82cc2d47e2becab0243f61f881a26ccb460d388060ee1bf6702a
BLAKE2b-256 checksum
How to use checksums
35befadbd53b76ed37023648a6a9642ef39d217c000ce23574e497050a8f36e2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.

Transparency log

Security for django-forge-log 1.0.0rc4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_forge_log-1.0.0rc4.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page