paper_trail-human

Gem Version CI

Transforms PaperTrail::Version records into structured, human-readable hashes ready for UI display — audit logs, timelines, activity feeds.

Resolves foreign keys to names, translates enums and constants, formats dates and numbers, and accepts custom transformations via lambda.

Table of Contents

1. Introduction

1a. Compatibility

paper_trail-human ruby activerecord paper_trail
0.5.x >= 3.1 >= 6.1 >= 12.0
0.4.x >= 3.1 >= 6.1 >= 12.0
0.3.x >= 3.1 >= 6.1 >= 12.0
0.2.x >= 3.0 >= 6.1 >= 12.0
0.1.x >= 2.7 >= 5.2 >= 9.0

CI matrix (0.5.x):

Rails PaperTrail Ruby
6.1 ~> 12.0 3.1, 3.2, 3.3, 3.4
7.0 ~> 13.0 3.1, 3.2, 3.3, 3.4
7.1 ~> 14.0 3.1, 3.2, 3.3, 3.4
7.2 ~> 15.0 3.1, 3.2, 3.3, 3.4
8.0 ~> 15.0 3.2, 3.3, 3.4

1b. Installation

Add to your Gemfile:

gem "paper_trail-human"

Then run:

bundle install
rails generate paper_trail:human:install

The generator creates an initializer at config/initializers/paper_trail_human.rb.

Important: This gem reads from the object_changes column. If your versions table doesn't have it, add it:

rails generate paper_trail:install --with-changes
rails db:migrate

1c. Quick Start

# config/initializers/paper_trail_human.rb
PaperTrail::Human.configure do |config|
  config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }
end

# Anywhere in your app
PaperTrail::Human.format(version)
# => {
#   user: "John",
#   event: "update",
#   model: "User",
#   item_id: 1,
#   created_at: 2026-05-29 12:00:00,
#   fields: [
#     { field: "Name", previous_value: "John", value: "John Smith" },
#     { field: "Company", previous_value: "Acme", value: "Globex" }
#   ]
# }

2. Configuration

2a. Global Options

PaperTrail::Human.configure do |config|
  # Resolve whodunnit IDs to names (default: nil, returns raw ID)
  config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }

  # Fields to exclude from output (default: %w[id created_at updated_at])
  config.ignored_fields = %w[id created_at updated_at]

  # Custom field name resolver (default: nil, uses I18n then humanize)
  config.field_name_resolver = ->(field, model) { ... }

  # Translate event names via I18n (default: false)
  config.translate_events = true

  # Post-processing hook (default: nil)
  config.after_format = ->(result, version) { result }
end

2b. Per-Model Fields

PaperTrail::Human.configure do |config|
  config.register "User" do |m|
    m.field :role, :enum, class_name: "UserRole", method: :label
    m.field :company_id, :relation, class_name: "Company", attribute: :name
    m.field :active, :boolean, true_label: "Active", false_label: "Inactive"
    m.field :bio, :text, max_length: 100, diff: true
    m.field :due_date, :date, format: "%d/%m/%Y"
    m.field :salary, :number, format: :currency, unit: "R$"
    m.field :score, :custom, resolve: ->(v) { "#{v} points" }
  end
end

2c. Item Name

Adds a human-readable identifier for the record to the output:

config.register "User" do |m|
  m.item_name :name
  # or with a lambda:
  m.item_name ->(version) { "User ##{version.item_id}" }
end

PaperTrail::Human.format(version)[:item_name]
# => "João Silva"

The item_name key is only present when the record exists and the attribute is configured.

In batch mode (format_collection), item names are preloaded to prevent N+1 queries.

2d. After Format Hook

Post-process every formatted result:

config.after_format = ->(result, version) {
  result[:record_url] = "/#{result[:model].tableize}/#{result[:item_id]}"
  result
}

The lambda receives the formatted hash and the original PaperTrail::Version, and must return the hash.

2e. Event Callbacks

Register multiple callbacks that fire after formatting. Useful for side effects like logging, notifications, or external integrations:

config.on_format do |result, version|
  AuditLog.push(result) if result[:event] == "destroy"
end

config.on_format do |result, version|
  SlackNotifier.notify(result)
end
  • Multiple callbacks, executed in order of registration
  • Fault-tolerant: errors in one callback don't block others or the return value
  • Runs after the after_format hook

3. Resolvers

3a. Relation

Resolves a foreign key to an attribute of the associated model.

m.field :company_id, :relation, class_name: "Company", attribute: :name
Option Description Default
class_name: The associated model class required
attribute: Attribute to display :name

In batch mode (format_collection), relations are preloaded to prevent N+1 queries.

3b. Enum

Resolves enum values to human labels.

# With a class that responds to a method
m.field :role, :enum, class_name: "UserRole", method: :label

# With a static mapping
m.field :status, :enum, mapping: { "active" => "Active", "inactive" => "Inactive" }

# With Rails native enum
m.field :role, :enum, from_model: "User"
m.field :role, :enum, from_model: "User", labels: { admin: "Administrator" }
Option Description
class_name: + method: Calls ClassName.method(value)
mapping: Static hash lookup
from_model: Reads from Model.defined_enums
labels: Custom labels for from_model

3c. Boolean

Custom labels for boolean fields:

m.field :active, :boolean, true_label: "Active", false_label: "Inactive"

3d. Custom

Arbitrary transformation via lambda:

m.field :score, :custom, resolve: ->(value) { "#{value} points" }

3e. Text

Truncates long text fields with optional diff stats:

m.field :body, :text, max_length: 100, show_diff_stats: true
# => "Lorem ipsum dolor sit amet..." (250 chars)

Diff mode — shows line-level additions and deletions:

m.field :body, :text, diff: true
# => {
#   field: "Body",
#   previous_value: "Old text...",
#   value: "New text...",
#   additions: 5,
#   deletions: 2,
#   summary: "+5/-2 lines"
# }
Option Description Default
max_length: Maximum characters before truncation 80
show_diff_stats: Append total char count false
diff: Enable line-level diff stats false

3f. Date

Formats date/time values:

m.field :due_date, :date, format: "%d/%m/%Y"
# => "30/05/2026"
Option Description Default
format: strftime format string "%Y-%m-%d"

Accepts Date, Time, DateTime, and parseable strings.

3g. Number

Formats numeric values:

m.field :amount, :number, format: :currency, unit: "R$"
# => "R$ 1,500.99"

m.field :rate, :number, format: :percentage
# => "85.50%"
Option Description Default
format: :default, :currency, :percentage :default
unit: Currency symbol (for :currency) nil
precision: Decimal places 2
delimiter: Thousands separator ","
separator: Decimal separator "."

4. Formatting

4a. Single Version

PaperTrail::Human.format(version)

Returns a hash with keys: user, event, model, item_id, created_at, fields, and optionally item_name.

Event-specific behavior:

  • create: fields omit previous_value
  • update: fields include both previous_value and value
  • destroy: fields omit value

4b. Collection

PaperTrail::Human.format_collection(user.versions)

Same as format but for multiple versions. Relations and item names are batch-loaded to prevent N+1 queries.

When using as:, returns a single joined string (separated by blank lines for text/markdown, newlines for HTML).

4c. Filtering Fields

PaperTrail::Human.format(version, only: [:name, :email])
PaperTrail::Human.format(version, except: [:password_digest])

4d. Output Formats

By default, methods return hashes. Use as: for string output:

PaperTrail::Human.format(version, as: :text)
# => "Updated User#1 by John at 2026-05-30\n  • Name: Old → New"

PaperTrail::Human.format(version, as: :markdown)
# => Markdown with header and table

PaperTrail::Human.format(version, as: :html)
# => HTML div with table (XSS-safe, escapes entities)

Built-in formats: :text, :markdown, :html. Custom formats can be registered (see 6b. Custom Formatters).

Works with both format and format_collection.

5. Timeline

Group versions by time period:

PaperTrail::Human.timeline(user.versions, group_by: :day)
# => {
#   "2026-05-28" => [{ user: ..., fields: [...] }, ...],
#   "2026-05-30" => [{ user: ..., fields: [...] }]
# }
group_by Format Example
:day %Y-%m-%d "2026-05-30"
:week %G-W%V "2026-W22"
:month %Y-%m "2026-05"
:year %Y "2026"

Supports only: and except: filters.

6. Extensibility

6a. Custom Resolvers

Register your own resolver types:

class MoneyResolver
  include PaperTrail::Human::Ports::Resolver

  def initialize(currency: "USD", **)
    @currency = currency
  end

  def resolve(value)
    "#{@currency} #{format('%.2f', value.to_f)}"
  end
end

PaperTrail::Human.configure do |config|
  config.register_resolver :money, MoneyResolver

  config.register "Order" do |m|
    m.field :total, :money, currency: "R$"
  end
end

The resolver class must include PaperTrail::Human::Ports::Resolver and implement #resolve(value).

For resolvers that need both old and new values, implement #resolve_pair? returning true and #resolve_change(previous_value, new_value).

6b. Custom Formatters

Register your own output formats:

class JsonFormatter
  def call(result)
    result.to_json
  end
end

PaperTrail::Human.configure do |config|
  config.register_formatter :json, JsonFormatter
end

PaperTrail::Human.format(version, as: :json)

The formatter class must implement #call(result) and return a string.

7. I18n

7a. Field Names

Field names are resolved in this order:

  1. Custom field_name_resolver lambda (if configured)
  2. I18n.t("activerecord.attributes.model_name.field_name") (if I18n available)
  3. Automatic humanization (removes _id suffix, titleizes)

Example: company_id → looks up activerecord.attributes.user.company_id → falls back to "Company".

7b. Event Labels

Enable translated event labels:

config.translate_events = true

The gem includes locale files for en and pt-BR. Add your own:

# config/locales/paper_trail_human.en.yml
en:
  paper_trail_human:
    events:
      create: "Created"
      update: "Updated"
      destroy: "Destroyed"
# config/locales/paper_trail_human.pt-BR.yml
pt-BR:
  paper_trail_human:
    events:
      create: "Criação"
      update: "Atualização"
      destroy: "Exclusão"

8. Architecture

Hexagonal (Ports & Adapters):

┌─────────────────────────────────────────────┐
│                   Core                       │
│  ChangeExtractor · FieldFormatter            │
│  EventTranslator · AfterFormat               │
│  Presenter · BatchPresenter · Timeline       │
│  ItemNameLoader · RelationLoader             │
├─────────────────────────────────────────────┤
│                   Ports                       │
│  Resolver (interface)                        │
├─────────────────────────────────────────────┤
│                  Adapters                     │
│  Resolvers: Relation, Enum, Boolean,         │
│             Custom, Text, Date, Number       │
│  Formatters: Text, Markdown, Html            │
│  (+ user-registered resolvers/formatters)    │
└─────────────────────────────────────────────┘
  • Core — pure formatting logic, no external dependencies
  • PortsResolver interface that every adapter implements
  • Adapters — concrete implementations (lazy-loaded via autoload)

The gem has zero dependencies beyond activerecord and paper_trail. Adapters are loaded on demand. The Railtie is optional — it works in non-Rails apps (Sinatra, Hanami, etc).

9. Requirements

  • Ruby >= 3.1
  • Rails >= 6.1 (or standalone ActiveRecord)
  • PaperTrail >= 12.0

10. Contributing

See CONTRIBUTING.md for guidelines on setting up the development environment, running tests, and submitting pull requests.

11. License

MIT. See LICENSE.txt.