English | Français
Release candidate.
django-forge-logis at1.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 final1.0.0is 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 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.
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_logfrom 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.
from rest_framework.viewsets import ModelViewSet
from forge_log.drf import AuditViewSetMixin
class ArticleViewSet(AuditViewSetMixin, ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializerNatively 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.
from django.contrib import admin
from forge_log.admin import AuditModelAdminMixin
@admin.register(Article)
class ArticleAdmin(AuditModelAdminMixin, admin.ModelAdmin):
passThe ActionLog table itself is browsable (read-only) in the Admin via
forge_log.admin.ActionLogAdmin.
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).
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.
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.
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.
ActionLog is append-only at the ORM level:
save()on an existing entry,instance.delete(), and the querysetupdate(),delete()andbulk_update()raiseforge_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.
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_idanduser_reprare kept as they were.entry.userraisesDoesNotExistonce the user is gone; readuser_id/user_reprinstead."constrained": the former behavior (real foreign key,SET_NULL:user_idbecomesNULLwhen the user is deleted,user_reprstays). 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.
python manage.py migrate forge_logapplies0003_alter_actionlog_user(drops the foreign key constraint onuser_id; no data change). To keep the former constraint, setFORGE_LOG["USER_FK"] = "constrained"first.- ORM updates and deletions of
ActionLognow raise. Wrap legitimate maintenance code inallow_mutation().forgelog_purgealready does. X-Forwarded-Foris no longer trusted by default. Behind a reverse proxy, setFORGE_LOG["TRUSTED_PROXY_COUNT"]to the number of proxies in front of the application, otherwiseipis the proxy address.- Write errors no longer fail the request: they are logged on the
forge_loglogger. SetFORGE_LOG["RAISE_ON_ERROR"] = Trueto get the previous behavior. forgelog_purgenow writes oneCUSTOMentry describing the purge, and refuses a retention below 1 day.
python manage.py forgelog_purge --days 90 # deletes entries older than 90 days
python manage.py forgelog_purge --days 90 --dry-runOr 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.
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
}object_idis aCharField(not a typed FK) to support non-integer PKs (UUID) without a table per model — the same trade-off asdjango-reversion/django-guardian. A composite index(content_type, object_id)compensates for the lack of a native FK constraint.track_actionwithoutget_instancecannot capture a creation (no PK in the URL before the view runs): useforge_log.drf.AuditViewSetMixinfor a DRF ViewSet, or provideget_instanceexplicitly.- The immutability guard is applied by the ORM only; it does not stop raw SQL (see Immutability of the journal).
threadbackend: queued entries are lost if the process is killed before the next flush (~THREAD_FLUSH_INTERVAL). For a strict durability guarantee, useon_commitorcelery.- The diff only compares the model's concrete fields
(
_meta.concrete_fields) or the explicit list passed tofields=— not implicit M2M relations.
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 pytestSee CONTRIBUTING.md to contribute, CHANGELOG.md for the release history, and RELEASING.md for the publishing process.