An audit log your customers can actually read
Consider a multi-tenant SaaS for clinic management. Its customers — clinic administrators — keep filing support tickets asking who changed a billing rate, or when a staff member's access was revoked. The audit data exists. It lives in an `audit_events` table with columns like `actor_id`, `action`, `entity_type`, `entity_id`, and a JSON blob for metadata. Only someone with database access or an internal admin panel can read it. That means every question becomes a support ticket.
The problem isn't that the data is missing. It is that the data wasn't written for the customer.
Most audit logs are written for the developer who built them. The `action` column holds a slug like `user.role.updated`. The `entity_id` is a UUID. The metadata blob has the diff, but only in the shape the code found convenient. To answer a clinic administrator's question, someone had to join against four tables, decode the blob, and write a sentence.
The fix is to store a human-readable sentence at write time, when the context that produced the event is still available.
When the billing service updates a rate, it knows the old value, the new value, who clicked the button, and which clinic they belong to. That is the moment to produce the string the customer will read. Not later, in a job that reconstructs context from fragments.
We store both. The structured columns stay for our own queries — filtering by action type, building reports, detecting anomalies. A `summary` column stores the rendered sentence for the customer-facing view.
def log_event(db, actor, action, entity, summary, metadata):
db.execute(
"""
INSERT INTO audit_events
(tenant_id, actor_id, action, entity_type,
entity_id, summary, metadata, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, now())
""",
(
actor.tenant_id, actor.id, action.slug,
entity.type, entity.id, summary,
json.dumps(metadata),
),
)The summary is written by the code that performs the action, not by a generic formatter. When a role changes, the billing service produces: "Priya Nair changed Anil Kumar's role from Billing Clerk to Clinic Admin." When a rate is updated: "System Admin updated the consultation rate for Cardiology from ₹800 to ₹950." The customer reads that and stops filing a ticket.
There is a tradeoff. The summary is denormalized. If you rename "Cardiology" to "Cardio Sciences" in the departments table, old audit entries still say Cardiology. We consider that acceptable. An audit log records what happened at the time it happened, not the current state of the world. The structured `entity_id` is still there if you need to join against current data.
The customer-facing view is a simple paginated list, filtered by `tenant_id`, ordered by `created_at` descending. We add a text search on the `summary` column using Postgres full-text search — a `tsvector` column and a GIN index. Customers type a staff member's name or a department and get matching entries. No joins, no blob decoding, no support ticket.
ALTER TABLE audit_events
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', summary)
) STORED;
CREATE INDEX audit_events_search_idx
ON audit_events USING gin(search_vector);One thing we would not build here: a filterable, faceted, exportable audit dashboard. Clinic administrators want to read what happened, not run analytics. A text search and a date filter cover nearly all of their questions. The remainder — bulk export for compliance reviews — is a CSV generated from the same table on request. That is an hour of work, and in our experience nobody asks for a second format.
The summary column is the part that matters. It shifts the work of explaining the event to the moment when the explanation is easiest to write, and it gives the customer something they can read without your help.