This release is a pre-release and may not be stable for production use.
django-forge-log
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 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 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.
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_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.
Upgrading to 1.0.0rc4
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.
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_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.
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
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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.