JRuby on Google App Engineでのログ出力

前置き

Google App Engine for Java(以下GAEJ)でJRubyスクリプトを書くにあたって、ログ出力をどうしたらいいのか、少し迷ってしまいませんか?

標準出力や標準エラー出力に書き出したデータは単に無視されるだけですし、書き込みモードでファイルを開けませんので、RubyLoggerクラスをそのまま使うことも出来ません。かといって、ログ出力無しでは何かと不便です。

ということで、GAEJ上のJRubyスクリプトでログ出力する方法を調べてみました。

Javaでのログ出力

JRubyJava上で動作しており、Javaのクラスを利用することができますので、Javaでどのようにログを出力するのかがわかれば解決したも同然です。

そこでGAEのデベロッパーガイドをざっと眺めてみました、が…。

どうにもそれっぽい項目が見当たりません。

よく探してみたら、全然関係なさそうな以下のページの下の方でさらっとログ出力について触れられていました。

ログ出力って結構重要な要素だと思うんですが、そうでもないんでしょうか…?

ともあれ、上のページによると、GAEJではjava.util.logging.Loggerクラスを利用してログ出力すれば良いそうです。つまり、JRubyからこのクラスを使わせてもらえばいいわけですね。

JRubyからjava.util.logging.Loggerを使う

インスタンス作成

java.util.logging.Loggerクラスのリファレンスを見てみると、どうやらLoggerクラスのインスタンスを取得するには複数の手段があるようです。

Rubyコードで書くと以下のような感じになります。

import java.util.logging.Logger

global_logger = Logger.getLogger(Logger::GLOBAL_LOGGER_NAME)
anonymous_logger = Logger.anonymous_logger
my_logger = Logger.getLogger('MyName')

上から順にグローバルロガー、匿名ロガー、名前付きロガー…らしいですが、Javaに明るくない私にはどう使い分けたらいいのかよくわかりません。正しく使い分けておけば、ログのネームスペースによってフィルタリングできる…とかそんな感じでしょうか。

ちょっと調べてみた範囲では、「とりあえず使うだけ」ならどれを使ってもあまり差は無さそうです。

ログ出力メソッド

Loggerクラスにはたくさんのメソッドがありますが、まず必要なのはレベル別のログ出力メソッドですね。

ログレベルを定義しているjava.util.logging.Levelクラスでは、レベルは低い順にFINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVEREとなっています。メソッドもそれぞれ同名のものが用意されているようです。

しかし、GAEダッシュボードのログ表示画面での表記はPython用のloggingモジュールで定義されているログレベルに合わせてあり、java.util.logging.Levelのそれとは名称が異なっています。

GAEJでは以下のような対応になっているようです。

my_logger.finest('hello') # = Debug
my_logger.finer('hello') # = Debug
my_logger.fine('hello') # = Debug
my_logger.config('hello') # = Debug
my_logger.info('hello') # = Info
my_logger.warning('hello') # = Warning
my_logger.severe('hello') # = Error

Criticalに対応するレベルは無いみたいです。とりあえずsevere, warning, info, あとはfine*1メソッド辺りがあれば十分、といったところでしょうか。

ログ出力レベルの変更

GAEではデフォルトのログ出力レベルはWARNING以上となっていますので、そのままではINFO以下のログは無視されてしまいます。*2

java.util.logging.Loggerクラスにはログ出力レベルを設定するsetLevelメソッドなどもありますので、それを利用しても良いのですが、設定ファイルによってアプリケーションのデフォルトログ出力レベルを指定できますので、今回はその方法をとることにします。

設定ファイルの書き方などは、先程も参照した以下のページに記載されています。

まずWEB-INF/appengine-web.xmlファイルを編集し、ログ設定ファイルの在り処をアプリケーションに教えてあげる記述を追加します。

$ vim WEB-INF/appengine-web.xml
<?xml version="1.0" encoding="utf-8"?>
<appengine-web-app xmlns="http://appengine.google.com/ns/1.0">
 …(略)…
  <system-properties>
    <property name="java.util.logging.config.file" value="WEB-INF/logging.properties" />
  </system-properties>
</appengine-web-app>

通常は既にsystem-properties要素が存在すると思いますので、その中に追加してやればOKです。

次に、上で指定したログ設定ファイルを作成します。1から書いてもいいのですが、SDK中にサンプルが用意されているようですので、そいつを持ってきます。

$ cp ../appengine-java-sdk-1.2.1/config/user/logging.properties WEB-INF

サンプルのままだとログ出力レベルがWARNINGのままですので、ここはひとまずFINEにでも変えておくとします。*3

$ vim WEB-INF/logging.properties
# Set the default logging level for all loggers to WARNING
#.level = WARNING
.level = FINE

これで全てのログが出力されるようになります。*4

その他のログ出力方法

実は、JRubyからログ出力するには、java.util.logging.Loggerクラスを使う方法の他に、もっと手軽な方法もあるみたいです。

$servlet_context.log('hello')

$servlet_contextというのはJRuby-Rackによって定義されるグローバル変数のようですね。名前からなんとなく役割は推測できますが、あまり詳しく調べてはいません。

この$servlet_contextを使ったログ出力は、logメソッド一つのみで、レベルの指定などもできません。このメソッドで出力したログは常にINFOレベルとして扱われるようです。

レベルに拘らず、ちょっとログに出したい…というようなときは、こちらのほうがお手軽で良いかもしれませんね。

GAEKit::Loggerを使う

あとはもう好きなようにログ出力すればいいだけなのですが、せっかくなのでこれもラッパークラスを書いてみました。前回紹介したGAE Toolkitに追加してあります。

GAEKitモジュール内のLoggerクラスがそれです。

ライブラリのインストール方法などは前回の記事を参照してください。

使い方

java.util.logging.Loggerクラスの三種類のインスタンスのうち、どれを使うようにすればいいのかよくわからなかったので、とりあえず全部呼び出せるようにしました。

引数を与えてGAEKit::Logger.newした場合は、通常の名前付きロガーが返ります。引数には自分のクラス名でも渡しておけばOKなんじゃないかと思います。*5

logger = GAEKit::Logger.new(self.class.name)
logger.info('This is a named logger.')

引数を与えずにGAEKit::Logger.newした場合は、匿名ロガーが返ります。

logger = GAEKit::Logger.new
logger.info('This is an anonymous logger.')

インスタンスを作らず、クラスメソッドでログ出力することもできます。この場合はグローバルロガーが使われます。

GAEKit::Logger.info('This is a global logger.')

また、レベル別ログ出力の各メソッド名は、RubyのLoggerクラスのそれに合わせてあります。java.util.logging.Levelとの対応は以下のようになります。

logger.debug('hello') # = FINE
logger.info('hello') # = INFO
logger.warn('hello') # = WARNING
logger.error('hello') # = SEVERE
logger.fatal('hello') # = SEVERE

基本的にfatal以外はGAEダッシュボードでの表記そのままと思ってもらって構いません。

java.util.logging.Level側に対応するレベルが無いために、fatalをCriticalとして扱えないのが残念ですが…。自分で勝手に定義できるものなのでしょうか。

そうそうそれと、java.util.logging.Loggerはメッセージに続く引数として例外オブジェクトを渡すと、そのスタックトレースをログに出力することができるようなので、それっぽいこともできるようにしました。

begin
  raise 'This is a dummy error.'
rescue
  logger.error('Caught error', $!)
end

ログを読みやすくする

ダウンロード

これで手軽にログ出力できるようになったわけですが、個人的にGAEダッシュボードのログ表示画面は若干使いにくいような気がします。

それに、やっぱりログはテキスト形式で扱えたほうが、色々加工しやすくて便利ですよね。

GAEドキュメントの以下のページによると、ログをテキスト形式でダウンロードする手段も用意されているようです。

こんな感じでコマンドを実行すると、全てのログを"mylogs.txt"としてダウンロードできます。

$ ../appengine-java-sdk-1.2.1/bin/appcfg.sh --num_days=0 --severity=0 request_logs . mylogs.txt

オプションの説明は上のページの"Command-Line Arguments"あたりを参照してください。

フォーマットの加工

ダウンロードできたはいいんですが、なんか妙に見難いフォーマットなんですよね…。リクエスト毎に、出力されたログが1行にまとめられているみたいです。

60.32.80.93 - - [16/Jul/2009:02:35:50 -0700] "GET / HTTP/1.1" 200 148 "http://appengine.g
oogle.com/" "Mozilla/5.0 (Windows; U; Windows NT 5.1; ja; rv:1.9.1) Gecko/20090624 Firefo
x/3.5 (.NET CLR 3.5.30729) AutoPager/0.5.2.2 (http://www.teesoft.info/),gzip(gfe)"^@0:124
7736950.569000 sun.reflect.NativeMethodAccessorImpl invoke0: hello^@: ^@1:1247736950.5720
00 sun.reflect.NativeMethodAccessorImpl invoke0: hello^@: ^@2:1247736950.573000 sun.refle
ct.NativeMethodAccessorImpl invoke0: hello^@: ^@3:1247736950.573000 sun.reflect.NativeMet
hodAccessorImpl invoke0: hello^@: ^@3:1247736950.573000 sun.reflect.NativeMethodAccessorI
mpl invoke0: hello^@: 

さすがにこのままでは読めないので、RubyのLoggerっぽいフォーマットに加工するスクリプトを書いてみました。*6

#!/usr/bin/env ruby
# coding: utf-8
require 'time'

class Formatter
  LEVELS = {
    '0' => [ 'D', 'DEBUG' ],
    '1' => [ 'I', ' INFO' ],
    '2' => [ 'W', ' WARN' ],
    '3' => [ 'E', 'ERROR' ],
    '4' => [ 'F', 'FATAL' ],
  }

  def filter(src = STDIN, dst = STDOUT)
    src.readlines.reverse_each do |line|
      @index = (@index || 0).succ
      @session = sprintf("#%05d", @index)
      line.split("\0")[1..-1].each do |log|
        dst.puts log if log = format(log)
      end
    end
  end

  def format(log)
    return nil unless log.match(/^(\d?): ?(.*)$/)
    message = $2
    return nil if message.strip.empty?
    @level = LEVELS[$1] || @level
    if message.match(/^(\d+)\.(\d+)\ (?:javax\.servlet\.ServletContext\ log|
            sun\.reflect\.NativeMethodAccessorImpl\ invoke0|
            sun\.reflect\.GeneratedMethodAccessor11\ invoke):\ (.*)$/x)
      message = $3
      time = Time.at($1.to_i, $2.to_i)
      @date = time.strftime('%Y-%m-%dT%H:%M:%S') + sprintf('.%06d', time.usec)
    end
    "#{@level[0]}, [#{@date} #{@session}] #{@level[1]} -- : #{message}"
  end
end

Formatter.new.filter(ARGF)

ログファイルを引数に渡すか、もしくは標準入力に流し込みます。

$ ./format_gaelog.rb mylogs.txt
D, [2009-07-16T18:35:50.569000 #00020] DEBUG -- : hello
I, [2009-07-16T18:35:50.572000 #00020]  INFO -- : hello
W, [2009-07-16T18:35:50.573000 #00020]  WARN -- : hello
E, [2009-07-16T18:35:50.573000 #00020] ERROR -- : hello
E, [2009-07-16T18:35:50.573000 #00020] ERROR -- : hello

こんな感じにフォーマットして出力されます。一応、ある程度は読み易くなるのではないかと。

ちなみに、日付・時刻の後の#から始まる数値は、プロセスIDではなくリクエスト番号(そのログ中に出現した何番目のリクエストか)を表しています。この数値が同じであれば、同一のリクエスト内で出力されたログだということになります。

*1:config〜finestの間ならなんでも

*2:ここでいうログ出力レベルは、GAEダッシュボードのログ表示画面における"Minimum Severity"とは別のものです。

*3:DEBUGレベルをFINESTに割り当てるのならFINEST、もしくはALLにします。

*4:今回はグローバルなログレベルを編集してしまいましたが、名前付きロガーで正しくログを分類している場合は、個別にデフォルトログレベルを指定したほうが良いのかもしれません。

*5:Javaではそんな感じのルールになってるみたい?

*6:sun.reflect.〜などの出力が邪魔なので無理矢理削ってますが、これって消せないんでしょうか。