Adds or changes activity logging (the audit trail) for a Django model in PostHog. Use when a model's writes must show in the Activity side panel or the advanced activity logs, when adding ModelActivityMixin, an ActivityScope, a model_activity_signal receiver, an activity describer, or field exclusions, when auditing which write paths of a model are logged, or when a change is missing from the activity log. Covers the receiver-module convention, writes the signal cannot see (QuerySet.update, bulk_create), the actor outside requests, and product models on a separate database. Trigger terms - activity log, audit log, audit trail, ModelActivityMixin, log_activity, changes_between, activity describer, who changed this.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Read docs/internal/activity-logging.md first. It carries the pipeline, the file locations, and the reasons. This skill carries the workflow and the gates.
Reference implementation: products/feature_flags/backend/activity_logging.py with its apps.py.
Separate-database reference: products/stamphog/backend/activity_logging.py.
The mixin hooks save() and delete().
It does not see QuerySet.update(), bulk_create(), bulk_update(), or raw SQL.
A receiver that logs only the API path gives an audit trail that says nobody changed a row a webhook changed.
rg -n "Thing\.objects|Thing\.all_teams" products/<name>/ --type py | rg -v "tests/"
rg -n "\.update\(|bulk_create\(|bulk_update\(" products/<name>/backend --type pyWrite the list down: which paths call save() (the mixin covers them) and which do bulk writes (you log those by hand in step 5).
Include tasks, Temporal activities, webhook handlers, and management commands.
ModelActivityMixin first in the bases: class Thing(ModelActivityMixin, TeamScopedRootMixin, ...).activity_logging_on_delete = True only when a hard delete must show in the log.ActivityScope in posthog/models/activity_logging/activity_log.py.ActivityScope enum in frontend/src/types.ts.hogli build:openapi (needs the dev stack) because the API scope filter enum is generated from the Literal.No migration is needed. The mixin adds no field.
Create products/<name>/backend/activity_logging.py.
One @mutable_receiver(model_activity_signal, sender=Thing) per model.
The receiver calls changes_between and log_activity; nothing else.
Keep the module import-light.
It must not import a viewset, a serializer, or a query runner.
ready() imports it at django.setup() in every process.
For a model without a .team foreign key (ProductTeamModel), read organization_id from Team on the main database inside the receiver.
products/<name>/backend/apps.py ready(): from products.<name>.backend import activity_logging # noqa: F401, PLC0415, with a one-line comment that says why it must connect in every process.model_activity:products.<name>.backend.activity_logging.<handler> to posthog/test/repo_invariants/setup_receivers_baseline.txt.A receiver that is not imported in ready() connects only where something else imports the module.
That is the silent failure this convention exists to prevent.
For each bulk write from step 1:
Change per field that actually changed, for rows whose value changed.bulk_log_activity (or log_activity for one row) with user=None when no request user exists, and a Trigger that names the job and its id.Do not wrap a request path in mute_selected_signals().
The flag is process-wide.
When the model is routed in products/db_routing.yaml:
using=router.db_for_write(Thing) to every log_activity and bulk_log_activity call in the product, including the receiver.
Without it, the audit row is written before the product row commits and survives its rollback.signal_exclusions (no row is logged when only they change).field_exclusions (dropped from the diff).field_with_masked_contents (the change is recorded, the values are not).field_name_overrides.defaultDescriber renders "X created / updated / deleted ".
Write products/<name>/frontend/activityDescriber.tsx and register it in frontend/src/lib/components/ActivityLog/describers.tsx when the default text does not say what changed in the user's words.
Invoke /writing-user-facing-copy before you write the strings.
Invoke /writing-tests first.
Add the tests listed under Tests in the doc, and no more.
hogli test products/<name>/backend/tests/<file>.py
hogli test posthog/test/repo_invariants/test_startup_import_budget.py
ruff check products/<name>/backend/activity_logging.py posthog/models/activity_logging --fixThen check the row in a shell: ActivityLog.objects.filter(scope="Thing").order_by("-created_at").first().detail.
Work down this list; each item is a distinct cause.
ready() (check setup_receivers_baseline.txt for the handler name).save() (step 1).signal_exclusions), or the diff was empty and force_save was not set.ACTIVITY_LOG_TRANSACTION_MANAGEMENT deferred the write to a commit that never came, or to the wrong connection (step 6).activity_visibility_restrictions hides it.b0fece0
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.