Prohlížeč zdrojového kódu

app/examples/audit_log/example.md

Náhled Zdrojový kód

Systém pro správu zákazníků energetické společnosti zpracovává citlivá data: smlouvy, spotřeby, faktury, zálohy. Kdykoli operátor něco změní, musí být jasné co, kdy, kdo a proč. Místo hotové knihovny (jako paper_trail) jsem implementoval vlastní auditní log přesně šitý na míru.
Důvody byly dva.
Zaprvé, hotové knihovny ukládají hodnoty tak jak jsou a cizí klíče zůstávají jako čísla. Jenže záznam `distributor_id: 7 → 12` je po čase k ničemu: distributor může být přejmenován nebo smazán. Potřeboval jsem jména resolvovat v době zápisu.
Zadruhé, celý systém se točí kolem zákazníků — každý záznam v historii musí být přiřazen ke konkrétnímu zákazníkovi, aby šlo na jednom místě vidět vše, co se v jeho datech dělo a nebylo třeba hledat historii mezi různými asociovanými objekty, které navíc třeba už ani neexistují. To je doménová logika, kterou generická knihovna neznala.
## Datový model
Záznamy jsou uloženy ve dvou tabulkách. `LogHeader` zastřešuje jednu „operaci" — zachytí čas, uživatele a zákazníka, jehož dat se změna týká. `LogItem` pak popisuje konkrétní změnu jednoho záznamu.
```ruby app/models/log_header.rb, app/models/log_item.rb
# log_headers: user_id, customer_id, note, created_at
# log_items: log_header_id, loggable_type, loggable_id, loggable_name, op, data (serialized hash)
class LogHeader < ApplicationRecord
belongs_to :user, optional: true
belongs_to :customer, optional: true
has_many :log_items
end
class LogItem < ApplicationRecord
belongs_to :log_header
belongs_to :loggable, polymorphic: true, required: false # musí přijmout i právě smazané záznamy
serialize :data, Hash
end
```
`loggable` je polymorfní odkaz — jeden `LogItem` může popisovat změnu smlouvy, faktury, zálohy i zákazníka samotného. `data` je serializovaný hash ve tvaru `{ "attribute_name" => [stará_hodnota, nová_hodnota] }`.
## Concern `Loggable`
Každý model, u nějž chceme sledovat změny, přihrne concern `Loggable` a deklaruje, které atributy logovat:
```ruby app/models/customer.rb, app/models/invoice.rb
class Customer < ApplicationRecord
include Loggable
log_attributes :all, except: [:created_at, :updated_at, :oms_count]
end
class Invoice < ApplicationRecord
include Loggable
log_attributes :all, except: [:customer_id, :created_at, :updated_at, :items, :other]
end
```
Zkratka `:all` zapíše seznam sloupců z `column_names` v době načtení třídy; `:except` z něj pak vyřadí zadané atributy.
```ruby app/models/concerns/loggable.rb
module Loggable
extend ActiveSupport::Concern
included do
class_attribute :logged_attributes
class_attribute :logged_options
self.logged_attributes = []
self.logged_options = {}
end
module ClassMethods
def log_attributes(*attrs)
self.logged_options = attrs.extract_options!
attrs.each do |attr|
if attr == :all
self.logged_attributes = column_names
else
self.logged_attributes << attr.to_s
end
end
self.logged_attributes = self.logged_attributes - Array.wrap(logged_options[:except]).collect(&:to_s)
end
end
end
```
### Callbacky: accumulate & flush
Callbacky neukládají změny do databáze hned. Místo toho přidají záznam do fronty uložené v thread-local proměnné skrze `Log.pending` a skutečný zápis proběhne až v `before_commit`, tedy v rámci téže transakce jako samotná změna dat. To umožňuje vícenásobné změny v rámci jedné transakce squashnout.
```ruby app/models/concerns/loggable.rb
module Loggable
extend ActiveSupport::Concern
included do
around_create :log_save
around_update :log_save
around_destroy :log_destruction
before_commit :log_save_pending
end
def log_save
if logged_attributes
changes = self.changes
.slice(*logged_attributes)
.to_h { |k, v| [k, [log_transform_value(k, v[0]), log_transform_value(k, v[1])]] }
Log.pending << {
customer: log_customer,
user: User.current,
op: new_record? ? :created : :updated,
note: Log.log_note,
attributes: changes,
obj: self
}
end
yield
end
def log_destruction
if logged_attributes && persisted?
changes = self.slice(*logged_attributes)
.to_h { |k, v| [k, [log_transform_value(k, v), log_transform_value(k, nil)]] }
Log.pending << {
customer: log_customer,
user: User.current,
op: :destroyed,
attributes: changes,
note: Log.log_note,
obj: self
}
end
yield
end
def log_save_pending
Log.save_pending
end
end
```
`around_create/update` jsou zvoleny záměrně: musí obejmout celý process ukládání tak, aby se `self.changes` vyhodnotilo ještě před tím, než ActiveRecord interně vyprázdní dirty tracking. Pokud se save nepovede, přeskočí se i uložení logů, protože fronta se flushne teprve v `before_commit`.
## Thread-local kontext
Aby byl záznam užitečný, potřebujeme vědět, kdo změnu provedl a (volitelně) proč. Oba údaje putují do logu jako kontext přes thread-local proměnné.
### Aktuální uživatel
Již dříve zavedený mechanismus v `ApplicationController` nastaví `User.current` pomocí `around_action` pro každý request:
```ruby app/controllers/application_controller.rb
around_action :track_current_user
def track_current_user
User.current = current_user
yield
ensure
User.current = nil
end
```
`ensure` zaručí, že vlákno (převzaté z poolu Pumy) nezanechá odkaz na starého uživatele.
```ruby app/models/user.rb
def self.current
Thread.current['current_user']
end
def self.current=(user)
Thread.current['current_user'] = user
end
```
### Poznámka k operaci
Formuláře mohou posílat volitelný parametr `log_note` — třeba „Přegenerováno" nebo „Storno faktury #42". Controller jej nastaví přes `Log.with_note`:
```ruby app/controllers/application_controller.rb
around_action :log_note
def log_note
if request.post? || request.patch? || request.put? || request.delete?
Log.with_note(params[:log_note]) { yield }
else
yield
end
end
```
```ruby app/models/log.rb
def self.with_note(note, &block)
prev = Thread.current['log_note']
Thread.current['log_note'] = note
yield
ensure
Thread.current['log_note'] = prev
end
```
`with_note` je zásobníkový (ukládá a obnovuje předchozí hodnotu), takže ho lze bezpečně vnořovat. Používá ho i business logika modelu přímo:
```ruby app/models/invoice.rb
def regenerate!
Log.with_note("Přegenerováno") do
# ... přepočítání položek, uložení
end
end
```
Při zpracovávání konkrétního úkolu/požadavku z úkolníčku v rámci aplikace se text ve formuláři přednastaví podle otevřeného úkolu.
## Transformace hodnot
Syrové hodnoty z databáze by v logu nebyly čitelné. Metoda `log_transform_value` je přeloží ještě před uložením do fronty: cizí klíče (např. `distributor_id: 7`) se nahradí čitelným jménem pomocí `loggable_name` (`"PREdistribuce"`), booleany se přeloží do češtiny a složené hodnoty jako adresa mají vlastní formátovač.
```ruby
module Loggable
def log_transform_value(attribute, value)
if value && attribute.end_with?('_id') && (assoc = self.class.reflect_on_association(attribute[0...-3]))
obj = assoc.klass.find(value) rescue nil
return obj.loggable_name if obj&.respond_to?(:loggable_name)
return obj.display_name if obj&.respond_to?(:display_name)
elsif value === true then return "ANO"
elsif value === false then return "NE"
elsif value.is_a?(Hash) && attribute == "address"
return Address::summary(value)
end
value
end
def loggable_name
return display_name if respond_to?(:display_name)
return name if respond_to?(:name)
""
end
end
```
## Cílový zákazník
Pro nalezení zákazníka k němuž má být záznam přiložen se buď zavolá `self.customer` nebo řetěz volání definovaný jako option pro `log_attributes`. Např. `log_attributes :all, customer: [:advance_period, :advance, :customer]` znamená: pro získání zákazníka volej `self.advance_period.advance.customer`. Případně objekt může prostě metodu `log_customer` přetížit po svém.
```ruby
module Loggable
def log_customer
if is_a?(Customer)
self
elsif respond_to?(:customer)
customer
elsif logged_options[:customer]
target = self
Array.wrap(logged_options[:customer]).each { |method| target = target.send(method) }
target
end
end
end
```
## Ukládání záznamu
Jeden request může model uložit víckrát. Například vytvoření zálohové faktury - vytvoří fakturu (1. uložení), vytvoří položky, pak doplní souhrn položek do faktury (2. uložení). Výsledný log by pak zbytečně obsahoval mezikroky. `Log.save_pending` je před zápisem squashne do jednoho. Pokud došlo ke smazání záznamu, zaznamenají se původní hodnoty.
```ruby app/models/log.rb
def self.save_pending
return if pending.empty?
pending.group_by {|p| p.values_at(:user, :customer, :note)}.each do |target_group, target_items|
user, customer, note = target_group
items = []
target_items.group_by {|i| i[:obj]}.each do |obj, obj_items|
attributes = {} # attribute_name => [old_value, new_value]
op = nil
# squash obj_items into one item
obj_items.each do |item|
if item[:op] == :destroyed
op = :destroyed
attributes = item[:attributes]
break
else
op ||= item[:op]
item[:attributes].each do |attr, values|
if attributes[attr] && attributes[attr].size > 1 # attribute already changed
if values.size > 1
attributes[attr][1] = values[1] # overwrite previous new_value with new new_value
end
else
attributes[attr] = values # overwrite unchanged or empty attribute
end
end
end
end
begin
raise "Missing id for #{obj.inspect}" if obj.id.nil?
items << LogItem.new(op: op, data: attributes, loggable: obj, loggable_name: obj.loggable_name) unless op == :updated && attributes.empty?
rescue => e
ExceptionNotifier.notify_exception(e) # notify about the error, but continue operation if logging of deleted item failed
end
end
LogHeader.create!(customer: customer, user: user, note: note, log_items: items) unless items.blank?
end
ensure
pending.clear
end
```
## Výsledek
Každá změna v systému je zachycena jako `LogHeader` s jedním nebo více `LogItem` záznamy. Operátor vidí u každého zákazníka nebo faktury kompletní historii: kdo, kdy, co změnil a případně proč. Vše v rámci téže databázové transakce, bez rizika nekonzistence mezi daty a logem.
![Ukázka výstupu](/audit_log_output.png)