こんにちは。勢いでAdvent Calendarへ登録してしまいました。
お付き合いいただければ嬉しいです。
本日はGoogle Analyticsぽいアクセス解析基盤を、Monolog + ElasticSearch(長いので以下ES)を使って自前で準備してみた話について書きたいと思います。
なぜ自前で?
- GAが、なんか、めんどくさい(雑)
- 既に別件でES環境構築済みで導入が楽だった
- 集計力がとにかく超強力で、カスタマイズされた表示項目を求める処理をなるべくES側へ分離したかった
- 集計後の表示項目をアプリケーション側で取得して、クライアント管理画面等で使いたい
- これは多分GAでも出来ますが、めんどくさい
面倒くさがりすぎですが、結果の集計にESの強力な集計クエリを使えるのは魅力です。
たまたま導入コストも低い状態でしたので挑戦してみました。
設計
MonologのElastica handlerを噛まして、アクセスログをESにとりあえず放り込んでみます。
放り込んだあとどうするのか、は放り込んでから考えます。ESあればどうにかなるなる。
手順
以下、AWS ES,IAM Roleは設定済みとします。
Elasticaの導入
ESのPHPクライアント、Elasticaをcomposerで入れます。
$ composer require ruflin/elastica
AWSのリソースへ接続するために,AWS SDKも入れておきます。
$ composer require aws/aws-sdk-php
config
ElasticaからAWS ESに接続するのは多少コツが入ります。
transport
,aws_region
を指定してあげないといけません。
elastica_client:
class: Elastica\Client
arguments:
-
url: "https://your_endpoint.ap-northeast-1.es.amazonaws.com"
transport: { type: 'AwsAuthV4', postWithRequestBody: true }
aws_region: 'ap-northeast-1'
# credencialを使う場合は以下も追加.但し,非推奨
# aws_access_key_id: "%your_access_key%"
# aws_secret_access_key: "%your_secret_key%"
こちらの記事を参考に致しました。ありがとうございます。http://kakakakakku.hatenablog.com/entry/2017/04/08/110423
さて、ESクライアントを作れたので、早速Monologのハンドラに渡してあげます。
# handler名。後でmonologへ渡す
elasticsearch_handler:
class: Monolog\Handler\ElasticSearchHandler
arguments:
- "@elastica_client" # さっきのElastica\Clientを指定
# 接続エラー等は無視するオプション
- { ignore_error: true }
index名,type名も指定できますが、デフォルトだとそれぞれmonolog
,record
になるようです。
詳しくは本家を参照。
symfony側でterminate eventをlistenする
ここで一旦、ロギングする側の設定へ移ります。
方針としては、
- EventListenerでロガーを仕込む
- 出力先に
ElasticSearchHandler
を登録する
どのEventに引っ掛けるか迷ったのですが、Responceを返した後が良いだろうということでTERMINATE
Eventに対してEventListenerを作ります。
<?php
namespace HogeBundle\EventListener;
use Symfony\Component\HttpKernel\Event\PostResponseEvent;
use Symfony\Bridge\Monolog\Logger;
/**
* Class AccessLogListener
*/
class AccessLogListener
{
private $logger;
/**
* @param Logger $logger
*/
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function onKernelResponse(PostResponseEvent $event)
{
$request = $event->getRequest();
# とりあえず、リクエストされたURIをセット。
# $eventから取得できるものなら何でも良い
$uri = $request->getRequestUri();
$this->logger->info($uri);
}
}
で、Service登録。
# 自作のlistenerを登録
access_log_listener:
class: HogeBundle\EventListener\AccessLogListener
arguments: ["@logger"]
tags:
- { name: kernel.event_listener, event: kernel.terminate, method: onKernelResponse }
- { name: monolog.logger, channel: access_log } # 独自のchannelを設定しておく
monologのchannelを新たに作るので、独自のchannel名を作っておきます。
Monologのchannel設定
先ほど設定したchannel
へESのハンドラを登録します。
monolog:
handlers:
main:
# ...
# channels: ['!access_log'] # 独自のchannelはmainから外す
access_log:
type: service # 独自のhandlerを使う時は、type: serviceを指定する
id: elasticsearch_handler # idでhandler名を設定してあげる
level: info
channels: ["access_log"]
channels: ["access_log"]
Kibanaで見てみる
ここまででアプリケーション側の設定は完了したので、Kibanaで見てみます。
https://your_endpoint.ap-northeast-1.es.amazonaws.com/_plugin/kibana/
…おおお!?なんかそれっぽいぞ!?
いろいろ放り込んでみる
今はURIのみしか取得してないので、EventLisnterでもっと情報を盛り込んでみます。
あんまり考えずいろいろ雑に突っ込みます。
public function onKernelResponse(PostResponseEvent $event)
{
$request = $event->getRequest();
$uri = $request->getRequestUri();
# とりあえずいろいろ設定
$info = [
'attributes' => $request->attributes->all(),
'request' => $request->request->all(),
'headers' => $request->headers->all(),
];
$this->logger->info($uri, $info);
}
結果
…うおぉ!?めっちゃいろいろ入ってる!(自分で入れたんだからそりゃそうなんですが)
集計してみる
とりあえずリクエストの情報が逐一入ってることは確認できました。
ここから、あるURIへのアクセス数の日時集計を集計してみます。
集計クエリについての細かい説明は省きますが、早速日時集計をDev toolsからリクエストしてみます。
※デフォルトだとindex名:monolog
,type名:record
なのでこんな感じ
GET monolog/record/_search
{
"size": 0,
"query": {
"match": {
"message": "/target/uri"
}
},
"aggs": {
"by_date": {
"date_histogram": {
"field": "datetime",
"interval": "day",
"format": "yyyy-MM-dd",
"time_zone": "Japan"
}
}
}
}
結果
{
"aggregations": {
"by_date": {
"buckets": [
{
"key_as_string": "2017-11-27",
"key": 1511708400000,
"doc_count": 196
},
{
"key_as_string": "2017-11-28",
"key": 1511794800000,
"doc_count": 200
},
{
"key_as_string": "2017-11-29",
"key": 1511881200000,
"doc_count": 311
},
{
"key_as_string": "2017-11-30",
"key": 1511967600000,
"doc_count": 599
},
{
"key_as_string": "2017-12-01",
"key": 1512054000000,
"doc_count": 445
},
...,
]
}
}
}
…これは…勝てる!(厨二)
doc_count
がレコード数、つまり対象URIへのアクセス数を日時毎に集計した値、となります。
さらにいろいろ計算してみる
ESの良いところは集計関数がかなり強力なところです。
日時集計した結果を更に、差分集計(前日比など)/累積集計/移動平均なんかもさくっと計算できます。
このあたりのよくある集計処理がDBレイヤーで片付くのはなかなか魅力ではないでしょうか。
実際にやってみましょう。
先ほどの日時集計date_histogram
句に続けて、差分集計derivative
,累積集計cumulative_sum
,移動平均moving_avg
を追加してみます。
"aggs": {
"by_date": {
"date_histogram": {
"field": "datetime",
"interval": "day",
"format": "yyyy-MM-dd",
"time_zone": "Japan"
},
"aggs": {
"deriv": {
"derivative": {
"buckets_path": "_count"
}
},
"cumul": {
"cumulative_sum": {
"buckets_path": "_count"
}
},
"movsum": {
"moving_avg": {
"buckets_path": "_count"
}
}
}
}
}
結果
{
"aggregations": {
"by_date": {
"buckets": [
{
"key_as_string": "2017-11-27",
"key": 1511708400000,
"doc_count": 196,
"deriv": {
"value": 196
},
"cumul": {
"value": 926
},
"movsum": {
"value": 46.8
}
},
{
"key_as_string": "2017-11-28",
"key": 1511794800000,
"doc_count": 200,
"deriv": {
"value": 4
},
"cumul": {
"value": 1126
},
"movsum": {
"value": 47.6
}
},
{
"key_as_string": "2017-11-29",
"key": 1511881200000,
"doc_count": 311,
"deriv": {
"value": 111
},
"cumul": {
"value": 1437
},
"movsum": {
"value": 87.6
}
},
{
"key_as_string": "2017-11-30",
"key": 1511967600000,
"doc_count": 599,
"deriv": {
"value": 288
},
"cumul": {
"value": 2036
},
"movsum": {
"value": 141.4
}
},
{
"key_as_string": "2017-12-01",
"key": 1512054000000,
"doc_count": 445,
"deriv": {
"value": -154
},
"cumul": {
"value": 2481
},
"movsum": {
"value": 261.2
}
},...
],...
やった!!それぞれ、計算済みの値が取得できています。
こ、これがやりたかったんや…(感涙)
他にも、分析のための様々な集計句や、オリジナルの集計句、それらを上記のように組み合せてかなり柔軟に値を求めることが可能です。
参考:https://www.elastic.co/guide/en/elasticsearch/reference/6.0/search-aggregations.html
今後の課題
フィルタリング
まだ対応できておりませんが、何でもかんでもロギングしてしまっているため余分なレコードが混じっています。(js/cssへのリクエストなど)
ESでの集計時に省いてもよいのですが、そもそもEventListerでレコード登録しない処理を挟む方が良いかと。
public function onKernelResponse(PostResponseEvent $event)
{
$request = $event->getRequest();
$uri = $request->getRequestUri();
// ...
// 集計対象にしないuriは省く
if ($this->isIgnored($uri)) {
return;
}
$this->logger->info($uri);
}
}
UUの取得
こちらも今後の対応ですが、現状単なるPVしか取れていないので、ユニークユーザ数等の処理ができておりません。
未検証なため多分ですが、
- リクエスト毎のsession等、ユーザを特定できるパラメータをレコードに含める
- MonologのProcesser使えばできそう
- ESでCardinality Aggregation(SQLでの
group by
的なやつ)を使って、session毎のユニーク数をとる
あたりを使えば対応できそうかな?と考えています。
まとめ
というわけで。
Monolog + ElasticSearchの組み合わせでアクセス集計基盤を作った話を紹介しました。
まだまだ対応できていないこともありますが、拾える情報のカスタマイズやESでの集計処理など、GAでは手の届かない部分での拡張性を感じております。
このようなこともできるよ、という一例になれば幸いです。