An audit log your customers can actually read
A client runs a multi-tenant SaaS for clinic management. Their customers — clinic administrators — kept filing support tickets asking who changed a billing rate, or when a staff member's access was revoked. The audit data existed. It lived in a `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 could read it. That meant every question became a support ticket.
The problem wasn't that the data was missing. It was 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 decided that was 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 deliberately did not build: a filterable, faceted, exportable audit dashboard. The clinic administrators wanted to read what happened, not run analytics. A text search and a date filter covered 95% of their questions. The remaining 5% — bulk export for compliance reviews — we handle by generating a CSV from the same table on request. It takes an hour to build and nobody has asked 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.