Audit Trail Instrumentation
How to make a resource show up in the Audit Trail (CMS → Account → Audit Trail, backed by audit.activity_logs). Add this to a controller whenever a resource needs a create/update/delete audit record — commonly driven by HIPAA / compliance requirements.
TL;DR — instrument a controller
module Clinical
class PatientsController < BaseController
include Audit::ActivityInstrumentation
audit_activity resource_type: 'Clinical::Patient'
# ...
end
endThat's it. Successful create / update / destroy on this controller now write an Audit::ActivityLog row (action create / update / delete) with the acting user, tenant, IP, user-agent, and session — visible in that tenant's Audit Trail.
What gets logged (and what doesn't)
The concern registers an after_action that writes a log only when all of these hold:
| Condition | Why |
|---|---|
Action is create, update, or destroy | Reads (index/show) are not logged; custom actions (cancel/resolve/…) use record_audit_event — see Custom actions |
| Response is 2xx | Failed/validation (4xx/5xx) requests are not logged |
current_tenant and current_user are present | Unauthenticated / no-tenant endpoints are skipped |
The write goes through Audit::Dispatch.log_activity, which is RLS-wrapped (sets the tenant GUC) and fail-open — an audit failure logs an error and returns nil; it never breaks the request.
Custom actions (state transitions)
The automatic mapping covers only create/update/destroy. A custom mutating action — cancel, resolve, dispense, a status transition — renders 200 and would otherwise go unaudited, even though "appointment cancelled" or "condition resolved" is often more interesting than a generic update.
Instrument those explicitly with record_audit_event, called from the member action after the transition succeeds:
def cancel
authorize @appointment, :update?
if @appointment.transition_to!(:cancelled, reason: params[:reason], user: current_user)
record_audit_event(event: 'cancelled', description: 'Cancelled appointment',
metadata: { reason: params[:reason] }.compact)
render_blueprint(@appointment, AppointmentBlueprint)
else
render_validation_errors(@appointment.errors.full_messages)
end
end- It logs an
updaterow (no new action-enum value, so the Audit Trail filter keeps working) tagged with a metadataeventdiscriminator ('cancelled','resolved', the new status, …), plus yourdescriptionand any extrametadata. - It reuses the same envelope as the automatic path (resource, facility, IP, session) — pass a specific
description; don't hand-rollDispatch.log_activity. - Same guards + fail-open behaviour as the automatic path (no-tenant/no-user → skipped; a logging failure never breaks the request).
- Don't try to audit custom actions by mapping arbitrary action names — that would over-log reads like
time_slots/export.
Options
audit_activity resource_type: 'Clinical::Condition' # label stored on the log (required)resource_id on create: it's read from params[:id], which exists for update/destroy but not create (there's no id in the URL yet). So create rows carry no resource_id by default. If you need it, override audit_resource_id in the controller to return the created record's id:
private
def audit_resource_id
params[:id] || @patient&.id # @patient is set in #create after save
endWhen NOT to use it
- Include on exactly ONE controller per hierarchy. Adding
audit_activityto a base controller and a subclass that inherits from it registers theafter_actiontwice → double audit rows. Opt in at the leaf controller. - Billing models are already audited via
Billing::ActivityLoggable(ActiveRecord callbacks onPayment,Invoice,Order, etc.). Do not also add this concern to billing controllers — you'd double-log. - Custom mutating actions (e.g.
cancel,resolve) are not auto-logged (onlycreate/update/destroymap). If a custom action needs auditing, callAudit::Dispatch.log_activity(...)explicitly from it. - Non-controller / background writes (Sidekiq jobs) have no request context — call
Audit::Dispatch.log_activitydirectly and pass an explicittenant:.
Related pieces
- Auth events (login / logout) are already instrumented in
Identity::SessionsController(they callAudit::Dispatch.log_activitydirectly, since login is tenant-agnostic). Failed-login is tracked separately (#1810). - The writer:
Audit::Dispatch.log_activity(backend/domains/audit/services/dispatch.rb) is the single entry point for activity logs not covered by a model callback. - The read side (the Audit Trail page +
GET /audit/activity_logs) shipped in #1064.
Platform-admin requests (audit.admin_access_logs)
Platform-admin controllers (Shared::PlatformAdminBaseController subclasses) are audited separately, in audit.admin_access_logs. The base controller writes one row per request before the action runs; the action then calls record_target!(record) to name what it touched. Unlike activity logs, these writes fail closed: the row is created with create! in a before_action and enriched with update!, so an audit failure fails the request rather than being swallowed. Call record_target! right after authorize, before the write, so refused and failed attempts are attributed too. Atomicity depends on the controller: in one that includes Shared::Concerns::PlatformAdminRls, the around_action wraps the action in a single transaction, so an unhandled exception rolls back both the mutation and the record_target! enrichment (the base row, written by the earlier before_action, survives). Elsewhere, a write that commits in its own RlsContext.with block stays committed even if a later record_target! raises.
record_target! sets target_tenant_id, which is what answers "what did platform admin X change in tenant Y" (idx_admin_access_logs_tenant_recent is on (target_tenant_id, created_at DESC) WHERE target_tenant_id IS NOT NULL, so rows with a NULL tenant are not in it). It is derived as:
| Record passed | target_tenant_id |
|---|---|
A tenant-scoped record (has tenant_id) | record.tenant_id |
An Identity::Tenant | record.id |
Anything else (a Practitioner, a User, a flag) | NULL |
Any record, with tenant_id: passed | the explicit value |
When the subject is tenantless but the mutation is tenant-scoped, pass the tenant explicitly. A practitioner is a person and has no tenant_id, so Identity::Admin::PractitionerRolesController records record_target!(role.practitioner, tenant_id: role.tenant_id), and PractitionersController#toggle_specialty_module passes the tenant it was asked to change (#2362). Keeping the practitioner as the subject keeps one doctor's admin history under one resource type.
Reliability & durability
The audit write is best-effort / fail-open: it runs in an after_action, in a separate transaction from the domain change, and a write failure (RLS, validation, DB) is swallowed so it can never break the request. Know the trade-offs:
-
Dropped writes are observable, not silent (#1814). Every swallowed audit-write failure routes through
Audit::Dispatch.report_audit_write_failure, which emits atag=audit_write_droppedlog line, increments the AppSignal counteraudit_write_dropped, and reports the exception to AppSignal taggedaudit_write_dropped(a tag, not a namespace — with a current transaction the block is applied to the live request transaction, and a namespace would move a successful request off itswebdashboards). Alert on that counter, and keep a log alert ontag=audit_write_droppedas the backstop: the log line is written first, so it survives the reporter itself failing.This reported to Sentry until #1823. The application does not run Sentry — the gem is not in the Gemfile and the initializer was commented out in full — so
defined?(::Sentry)was always false and the only signal a dropped row produced was a log line with nothing watching it. AppSignal is the monitoring this application actually runs. The gem is ingroup :staging, :production, andconfig/appsignal.ymlsetsactive: falsefor development, staging and test — so in staging the counter and the error go nowhere, and the log line is the only signal. On a pilot, alert on the log. -
In test, a dropped write fails the test (#1823). The same rescue re-raises when
Audit::Dispatch.raise_on_dropped_write?is true, which it is in the test environment. Before this, a request test could assert a 200 while the audit row it was supposed to write silently never landed — which is how several instrumented controllers came to have no audit rows and passing tests. A test that means to exercise the production fail-open path says so explicitly:Audit::Dispatch.stubs(:raise_on_dropped_write?).returns(false)The commonest cause of a dropped write in a request test is an actor stubbed as a bare UUID:
audit.activity_logs.user_idreferencesidentity.users, so the actor has to be a persisted row. -
Non-atomic with the mutation. The audit row commits after the domain change, so a crash in between leaves an unaudited change. This is accepted as best-effort for now; a non-lossy guarantee (transactional model-callback auditing, or a transactional outbox) is a deliberate future decision — raise it with compliance if PHI auditing must be non-lossy.
-
Coverage is guarded (#1814).
backend/domains/audit/test/audit_coverage_test.rbfails CI if a new mutatingClinical::*controller ships without eitheraudit_activityor an entry on theNOT_YET_AUDITEDbacklog — so the gap can't grow silently.
Currently instrumented controllers
Add to this list as you opt controllers in:
Clinical::PatientsController—Clinical::PatientClinical::ConditionsController—Clinical::ConditionClinical::AppointmentsController—Clinical::AppointmentClinical::MedicationRequestsController—Clinical::MedicationRequest
Testing your instrumentation
The concern itself is covered by backend/domains/audit/test/concerns/activity_instrumentation_test.rb. For a newly-instrumented controller you don't need to re-test the concern — just confirm the class declares it:
assert_equal 'Clinical::Patient', Clinical::PatientsController.audit_activity_options[:resource_type]