Railsの計測を支えるActiveSupport::Notificationsについて
rails

Railsの計測を支えるActiveSupport::Notificationsについて

このエントリーをはてなブックマークに追加

こんにちは、技術推進室の篠崎(前回より5ヶ月ぶり)です。

Ruby on Rails(以下Rails)にはイベント(SQLを実行する、テンプレートを描画するなど)に対してフック(計測)できる仕組みがあるのはご存じですか?

おなじみのログもこのフックの仕組みを利用して出力されています。(1行目除く)

Started GET "/samples/1.json" for 127.0.0.1 at 2014-12-10 12:35:30 +0900
Processing by SamplesController#show as JSON
  Parameters: {"id"=>"1"}
  Sample Load (0.5ms)  SELECT `samples`.* FROM `samples` WHERE `samples`.`id` = 1 LIMIT 1
  Sample Load (0.5ms)  SELECT `samples`.* FROM `samples`
  Rendered samples/_simple.json.jbuilder (1005.6ms)
  Rendered samples/show.json.jbuilder (1014.4ms)
Completed 200 OK in 1027ms (Views: 1019.9ms | ActiveRecord: 1.0ms)

このようなRailsのフックはActiveSupport::Notificationsライブラリを利用して構築されています。

今回はこのActiveSupport::Notificationsとそれを利用して出力されるRailsのログについて調べてみました。

確認したバージョン

以下の環境で確認しました。

  • Rails 4.0.12
  • Ruby 2.1.5

ActiveSupport::Notificationsとは?

イベントを計測するためのライブラリで以下のAPIを提供します。

  • イベントを購読する
  • イベントを計測する

イベントを購読する

計測された情報で何か処理をする場合にこのAPIを利用します。

購読したイベントが計測されると通知されるようになります。(正確にはイベント開始、終了時に通知がくる)

通知時にはイベント情報が引数として渡され、そのイベント情報の中には処理時間なども含まれます。

以後、イベントを購読する者をSubscriberと呼びます。

イベントを計測する

そのままでイベントを計測する時に利用されるAPIです。

計測するとSubscriber「たち」に通知されます。

計測時に情報を付与する事もでき、独自の情報(以後payload)もSubscriberに渡せるようになります。

サンプル

イベント購読、計測のサンプルです、実行するとoutput.sampleを購読したSubscriberにだけ通知され、payload(name)とブロックの処理時間が出力されます。

class SampleScriber < ActiveSupport::Subscriber
  def output(event)
    payload = event.payload
    puts "#{payload[:name]} 処理時間: #{event.duration}ms"
  end
end

# output.sampleを購読する
SampleScriber.attach_to :sample

# output.sampleを計測する
ActiveSupport::Notifications.instrument('output.sample', name: 'aaa') do
  sleep(0.1)
end

# output.sampleを計測する
ActiveSupport::Notifications.instrument('output.sample', name: 'bbb') do
  sleep(0.2)
end

# output.exampleを計測する
ActiveSupport::Notifications.instrument('output.example', name: 'ccc') do
  sleep(0.3)
end

実行するとaaaとbbbのname、処理時間が表示されます。cccはoutput.sampleではなくoutput.exampleを計測しているのでSubscriberには通知されません。

$ bundle exec ruby sample.rb
aaa 処理時間: 100.28ms
bbb 処理時間: 200.24900000000002ms
イベント(output.sample)を購読する

ActiveSupport::Subscriberを継承したクラスを作成しoutput.sampleを購読しています。

class SampleScriber < ActiveSupport::Subscriber
  def output(event)
    payload = event.payload
    puts "#{payload[:name]} 処理時間: #{event.duration}ms"
  end
end

# output.sampleを購読する
SampleScriber.attach_to :sample
ActiveSupport::Subscriber

基本、このクラスを継承してSubscriberを作成します。

ActiveSupportではSubscriberクラスとそれを継承したログ用のLogSubscriberが用意されています。

attach_toメソッド

ActiveSupport::Subscriberに実装されているメソッドでattachしたクラスのパブリックなメソッド名を自動で購読(メソッド名.名前空間)します。(start、finishを除く)

このサンプルではメソッド名output、名前空間:sampleなのでoutput.sample計測時にoutputメソッドに通知されるようになります。

イベント(output.sample)を計測する

計測はActiveSupport::Notifications.instrumentメソッドで行います。

ブロック内の処理を計測してoutput.sampleを購読しているSubscriberに通知されます。

# output.sampleを計測する
ActiveSupport::Notifications.instrument('output.sample', name: 'aaa') do
  sleep(0.1)
end
ActiveSupport::Notifications.instrument

Subscriberが存在する場合、内部ではActiveSupport::Notifications::Instrumentor.instrumentを呼んでいます。実質な処理はこのメソッドです。

def instrument(name, payload={})
  start name, payload
  begin
    yield payload
  rescue Exception => e
    payload[:exception] = [e.class.name, e.message]
    raise e
  ensure
    finish name, payload
  end
end

instrumentメソッドに渡されたブロックを実行します。あわせて前後処理(ActiveSupport::Subscriber.start、ActiveSupport::Subscriber.finish)も実行します。

def start(name, id, payload)
  e = ActiveSupport::Notifications::Event.new(name, Time.now, nil, id, payload)
  parent = event_stack.last
  parent << e if parent

  event_stack.push e
end

def finish(name, id, payload)
  finished  = Time.now
  event     = event_stack.pop
  event.end = finished
  event.payload.merge!(payload)

  method = name.split('.').first
  send(method, event)
end

startとfinishで計測し、finishの最後でイベントを購読しているSubscriberに処理時間含むイベント情報を引数にして通知されます。

サンプルではsleepの処理時間を含んだイベント情報eventを引数にしてSampleSubscriber.outputに通知され、output内でその情報を出力しているわけです。

すごく簡単なサンプルですが概要を理解できるのではないかと思います。

Railsのログ出力

冒頭でRailsにはフックの仕組みがあり、その仕組みはActiveSupport::Notificationsで構築されログ出力はその仕組みを利用していると書きました。

サンプルが理解できればRailsではどのようにフックを提供しているか想像できるのではないでしょうか?

例えばActiveRecordなら以下のような感じです。

Sample Load (0.5ms)  SELECT `samples`.* FROM `samples
  1. SubscriberがSQL実行(イベント)を購読する
  2. SQL実行を計測する
  3. Subscriberに通知されログ出力される

SubscriberがSQL実行(イベント)を購読する

ActiveRecordのSubscriberはActiveRecord::LogSubscriberでログ出力に特化したActiveSupport::LogSubscriberを継承しているクラスです。

ログ出力はsqlメソッドで行われます。

payloadで実行するSQLなども渡され、処理時間や色情報などとあわせてメッセージを作成しデバッグログを出力しています。

def sql(event)
  self.class.runtime += event.duration
  return unless logger.debug?

  payload = event.payload

  return if IGNORE_PAYLOAD_NAMES.include?(payload[:name])

  name  = "#{payload[:name]} (#{event.duration.round(1)}ms)"
  sql   = payload[:sql].squeeze(' ')
  binds = nil

  unless (payload[:binds] || []).empty?
    binds = "  " + payload[:binds].map { |col,v|
      render_bind(col, v)
    }.inspect
  end

  if odd?
    name = color(name, CYAN, true)
    sql  = color(sql, nil, true)
  else
    name = color(name, MAGENTA, true)
  end

  debug "  #{name}  #{sql}#{binds}"
end

名前空間active_recordで購読しているのでSQL実行(sql.active_record)が計測された時に通知されます。

ActiveRecord::LogSubscriber.attach_to :active_record

SQL実行(sql.active_record)を計測する

SQL実行(sql.active_record)はActiveRecord::ConnectionAdapters::AbstractAdapterクラスのlogメソッドで計測されます。

def log(sql, name = "SQL", binds = [])
  @instrumenter.instrument(
    "sql.active_record",
    :sql           => sql,
    :name          => name,
    :connection_id => object_id,
    :binds         => binds) { yield }
rescue => e
  message = "#{e.class.name}: #{e.message}: #{sql}"
  @logger.error message if @logger
  exception = translate_exception(e, message)
  exception.set_backtrace e.backtrace
  raise exception
end

このlogメソッドは2カ所から呼ばれます、SQLの結果をDB、キャッシュから取得した場合です。

  • ActiveRecord::ConnectionAdapters::MySQL2Adapterのexecuteメソッド ※ MySQLの場合
  • ActiveRecord::ConnectionAdapters::QueryCacheのcache_sqlメソッド

executeメソッドを見るとブロックで@connection.query(sql)を渡しています。

def execute(sql, name = nil)
  if name == :skip_logging
    @connection.query(sql)
  else
    log(sql, name) { @connection.query(sql) }
  end
rescue ActiveRecord::StatementInvalid => exception
  if exception.message.split(":").first =~ /Packets out of order/
    raise ActiveRecord::StatementInvalid.new("'Packets out of order' error was received from the database. Please update your mysql bindings (gem install mysql) and read http://dev.mysql.com/doc/mysql/en/password-hashing.html for more information. If you're on Windows, use the Instant Rails installer to get the updated mysql bindings.", exception.original_exception)
  else
    raise
  end
end

つまり以下のActiveRecordログの0.5msは@connection.query(sql)の処理時間なんだとかわかります。

Sample Load (0.5ms)  SELECT `samples`.* FROM `samples

ActiveRecordのログはSQL実行時(DB or キャッシュ)に以下の情報を出力する事がわかりました。

  • モデル
  • 取得方法(検索、キャッシュ)
  • @connection.query(sql)の処理時間
  • 実施されるSQL

他のLogSubscriber

Railsには他にもLogSubscriberがあります。

ここからたどっていくと、どのログがなんのイベント時に出力され、何の情報を出力しているかわかります。

  • ActionController::LogSubscriber
  • ActionView::LogSubscriber
  • ActionMailer::LogSubscriber
  • など

独自Subscriber

仕組みさえ理解できれば独自のSubscriberを作成するのは難しくないと思います。

特定のSQLにだけExplainしてログ出力するLogSubscriberや処理時間が50msを超えたSQLだけログ出力するLogSubscriberなど便利なものが作成できるかもしれません。(既にあるかもしれません)

また独自のイベントを計測する箇所とそのイベントを購読するSubscriberを用意すればアプリケーションの特定部分の処理を計測したりする事もできます。

ただのフックとしても利用できるので他にも色々出来るのではないでしょうか?

※ Railsでフックできる箇所はactive_support_instrumentaionで確認できます。

最後に

ActiveSupport::Notifications、利用したRailsのログ出力について概要は理解できたでしょうか?

他のLogSubscriberも調べて頂いて「出力されるログのmsは何の処理時間なんだろう?、Renderedの処理時間合計とViewsの処理時間はあわないけどなんでだろう?」など質問された時に回答できるとかっこいいかもしれません。

今回書かせて頂いた内容、実はここ見れば一発だぞとか全然間違っているとかあれば @ryshinoz にご連絡頂けると助かります。

以上


名無しのエンジニア
コードレビューが無い世界から、コードレビューがある世界に移った僕が気をつけたい10項目
ソシオメディア UX戦略フォーラム 2014 Winterに参加しました