はじめに
monologから出力したログをfluentdで集約するを書いていて、monolog用のparserを公開してもいいのでは、という気持ちになり、頑張って公開してみました。
https://rubygems.org/gems/fluent-plugin-monolog
公開に際して、ハマったところなどありましたので、新たに投稿しておきます。
monologから出力されるログのフォーマット
monologのデフォルトのフォーマッターは、LineFormatterです。
trait FormattableHandlerTrait
{
/** 中略 **/
/**
* Gets the default formatter.
*
* @return FormatterInterface
*/
protected function getDefaultFormatter(): FormatterInterface
{
return new LineFormatter();
}
}
LineFormatterは下記のようなログを出力します。
[2016-07-08 16:12:42] myapp.ERROR: Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException: No route found for "GET /path/to/execute": Method Not Allowed (Allow: POST) (uncaught exception) at /apps/vendor/symfony/http-kernel/Symfony/Component/HttpKernel/EventListener/RouterListener.php line 148 {"exception":"[object] (Symfony\\Component\\HttpKernel\\Exception\\MethodNotAllowedHttpException(code: 0): No route found for \"GET /path/to/execute\": Method Not Allowed (Allow: POST) at /apps/vendor/symfony/http-kernel/Symfony/Component/HttpKernel/EventListener/RouterListener.php:148, Symfony\\Component\\Routing\\Exception\\MethodNotAllowedException(code: 0): at /apps/vendor/symfony/routing/Symfony/Component/Routing/Matcher/UrlMatcher.php:101)"} {"url":"/path/to/execute","ip":"192.168.xx.xx","http_method":"GET","server":"*.example.com","referrer":null,"server_ip":"192.168.xx.xx","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"}
まとめると、以下のようなフォーマットになります。
[{$timestamp}] {$channel}.{$level} ${message} ${context} ${extra}
正規表現
元々パースされることを考えられているフォーマットではないと思うので、なかなかパターンを絞りにくいのですが、下記の特徴があるので、何とか正規表現でパースできています。
-
context
とextra
は、array => json_encodeされる - PHPのjson_encodeの仕様で、空のarray()は
[]
になる({}
ではない)
/^\[(?<time>[\d\-]+ [\d\:]+)\] (?<channel>.+)\.(?<level>(DEBUG|INFO|NOTICE|WARNING|ERROR|CRITICAL|ALERT|EMERGENCY))\: (?<message>[^\{\}]*) (?<context>(\{.+\})|(\[.*\])) (?<extra>(\{.+\})|(\[.*\]))\s*$/
※パースできないパターンを見つけたら、issuesください。
fluent-plugin-monologの使い方
下記のコマンドでプラグインを追加します。
$ td-agent-gem install fluent-plugin-monolog
in_tail
のformatにmonolog
を指定します。
<source>
@type tail
path /path/to/example.log
format monolog
pos_file /path/to/example.log.pos
tag example.stream
</source>
rubygems.orgに公開する際にハマったこと
正直、本筋ではないのですが、rubygems.orgに公開した後で、数時間ハマってしまったので、書き記しておきます。
ちなみに、私はruby初心者なので、常識的なことさえ知らない可能性がありますので、生暖かい目で見ていただけると幸いです。
rake test
は通るけど、td-agent-gem install
で動かない
結論、TextParser
のnested classとしてパーサーを定義しないといけなかった
元々、lib/fluent/plugin/parser_apache2.rbをコピーしてきて、ちょっと正規表現変えればいいと思っていました。
で、Apache2Parser見ると、下記のようになっているわけです。
require 'fluent/plugin/parser'
module Fluent
module Plugin
class Apache2Parser < Parser
Plugin.register_parser('apache2', self)
REGEXP = /^(?<host>[^ ]*) [^ ]* (?<user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>\S+)(?: +(?<path>[^\"]*?)(?: +\S*)?)?" (?<code>[^ ]*) (?<size>[^ ]*)(?: "(?<referer>[^\"]*)" "(?<agent>[^\"]*)")?$/
TIME_FORMAT = "%d/%b/%Y:%H:%M:%S %z"
def initialize
super
@time_parser = TimeParser.new(TIME_FORMAT)
@mutex = Mutex.new
end
このコードを丸っと、持ってきて、tagomorisさんのはてブとか見てプラグイン作って、rake test
も通るんですが、TimeParser.new
のところでどうしてもエラーになってしまいます。
では、このTimeParserはどこで定義されているかというと、lib/fluent/plugin/parser.rbに、その名前を見つけることができます。
module Fluent
module Plugin
class Parser < Base
include OwnedByMixin
class ParserError < StandardError; end
# 省略
class TimeParser
def initialize(time_format)
ここでもTextParserは出てきません。
最終的に、DevNullParserで始めるFluentd パーサプラグイン入門 - Qiitaに、掲載されていたコードを見て、真似してうまく行きました。
module Fluent
class TextParser
#パーサープラグインの名前
class DevNullParser < Parser
# このプラグインをパーサプラグインとして登録する
Plugin.register_parser('dev_null', self)
うーん、Parserプラグインを継承しているから、ネストクラスのTimeParserにもそのままアクセスできるイメージでいたのですが、正直、未だによく分かっていません。
参考URL
一度、公開したらバージョンアップするしかない
pluginとして、インストールしたときにうまく動かなかったので、何度もrake release
しなければいけなかったのですが、とにかくバージョンをあげていかないことには、リリースできません。
言ってみれば、セマンティック・バージョニングを強制されることになります。
セマンティック・バージョニングに抵抗はないのですが、ローカルでの再現に手間取ったのと、前述のTimeParserがnewできない問題があったので、パッチバージョンを何度も上げる結果になってしまいました。
参考URL
最終的なコード(v0.1.5)
最終的に下記のようなコードになりました。
require 'json'
module Fluent
class TextParser
class MonologParser < Parser
Fluent::Plugin.register_parser('monolog', self)
REGEXP = /^\[(?<time>[\d\-]+ [\d\:]+)\] (?<channel>.+)\.(?<level>(DEBUG|INFO|NOTICE|WARNING|ERROR|CRITICAL|ALERT|EMERGENCY))\: (?<message>[^\{\}]*) (?<context>(\{.+\})|(\[.*\])) (?<extra>(\{.+\})|(\[.*\]))\s*$/
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
def initialize
super
@time_parser = TimeParser.new(TIME_FORMAT)
@mutex = Mutex.new
end
def patterns
{'format' => REGEXP, 'time_format' => TIME_FORMAT}
end
def parse(text)
m = REGEXP.match(text)
unless m
yield nil, nil
return
end
time = m['time']
time = @mutex.synchronize { @time_parser.parse(time) }
channel = m['channel']
level = m['level']
message = m['message']
context = JSON.parse(m['context'])
extra = JSON.parse(m['extra'])
record = {
"channel" => channel,
"level" => level,
"message" => message,
"context" => context,
"extra" => extra
}
record["time"] = m['time'] if @keep_time_key
yield time, record
end
end
end
end
おわりに
packagistデビューする前に、RubyGemsデビューすることになりました。
Webアプリケーション作るのには、PHPはかなり書きやすくて気に入ってはいますが、ツール系はPHPはほとんどなくて、今回のようにツール系のプラグインを書こうとすると、RubyとかPythonになるので、多少書けないといけないのかなと、最近はモヤモヤしております。