Kulcare Docs
Developer

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
end

That'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:

ConditionWhy
Action is create, update, or destroyReads (index/show) are not logged; custom actions (cancel/resolve/…) use record_audit_event — see Custom actions
Response is 2xxFailed/validation (4xx/5xx) requests are not logged
current_tenant and current_user are presentUnauthenticated / 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 update row (no new action-enum value, so the Audit Trail filter keeps working) tagged with a metadata event discriminator ('cancelled', 'resolved', the new status, …), plus your description and any extra metadata.
  • It reuses the same envelope as the automatic path (resource, facility, IP, session) — pass a specific description; don't hand-roll Dispatch.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
end

When NOT to use it

  • Include on exactly ONE controller per hierarchy. Adding audit_activity to a base controller and a subclass that inherits from it registers the after_action twice → double audit rows. Opt in at the leaf controller.
  • Billing models are already audited via Billing::ActivityLoggable (ActiveRecord callbacks on Payment, 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 (only create/update/destroy map). If a custom action needs auditing, call Audit::Dispatch.log_activity(...) explicitly from it.
  • Non-controller / background writes (Sidekiq jobs) have no request context — call Audit::Dispatch.log_activity directly and pass an explicit tenant:.
  • Auth events (login / logout) are already instrumented in Identity::SessionsController (they call Audit::Dispatch.log_activity directly, 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 passedtarget_tenant_id
A tenant-scoped record (has tenant_id)record.tenant_id
An Identity::Tenantrecord.id
Anything else (a Practitioner, a User, a flag)NULL
Any record, with tenant_id: passedthe 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 a tag=audit_write_dropped log line, increments the AppSignal counter audit_write_dropped, and reports the exception to AppSignal tagged audit_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 its web dashboards). Alert on that counter, and keep a log alert on tag=audit_write_dropped as 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 in group :staging, :production, and config/appsignal.yml sets active: false for 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_id references identity.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.rb fails CI if a new mutating Clinical::* controller ships without either audit_activity or an entry on the NOT_YET_AUDITED backlog — so the gap can't grow silently.

Currently instrumented controllers

Add to this list as you opt controllers in:

  • Clinical::PatientsController — Clinical::Patient
  • Clinical::ConditionsController — Clinical::Condition
  • Clinical::AppointmentsController — Clinical::Appointment
  • Clinical::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]

On this page