Monolog + AWS ElasticSearchでアクセス解析基盤を作ってみた

こんにちは。勢いで[Advent Calendar](https://qiita.com/advent-calendar/2017/symfony)へ登録してしまいました。
お付き合いいただければ嬉しいです。

本日はGoogle Analyticsぽいアクセス解析基盤を、Monolog + ElasticSearch(長いので以下ES)を使って自前で準備してみた話について書きたいと思います。

# なぜ自前で？

* GAが、なんか、**めんどくさい**（雑）
* 既に別件でES環境構築済みで導入が楽だった
    * 集計力がとにかく超強力で、カスタマイズされた表示項目を求める処理をなるべくES側へ分離したかった
* 集計後の表示項目をアプリケーション側で取得して、クライアント管理画面等で使いたい
    * これは多分GAでも出来ますが、**めんどくさい**

面倒くさがりすぎですが、結果の集計にESの強力な集計クエリを使えるのは魅力です。
たまたま導入コストも低い状態でしたので挑戦してみました。

# 設計
MonologのElastica handlerを噛まして、アクセスログをESにとりあえず放り込んでみます。
放り込んだあとどうするのか、は放り込んでから考えます。ESあればどうにかなるなる。

# 手順
以下、AWS ES,IAM Roleは設定済みとします。

## Elasticaの導入
ESのPHPクライアント、[Elastica](https://github.com/ruflin/Elastica)をcomposerで入れます。

```
$ composer require ruflin/elastica 
```

AWSのリソースへ接続するために,AWS SDKも入れておきます。

```
$ composer require aws/aws-sdk-php
```

## config
ElasticaからAWS ESに接続するのは多少コツが入ります。

`transport`,`aws_region`を指定してあげないといけません。

```service.yml
    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のハンドラに渡してあげます。

```service.yml
    # handler名。後でmonologへ渡す
    elasticsearch_handler:
         class: Monolog\Handler\ElasticSearchHandler
         arguments:
             - "@elastica_client" # さっきのElastica\Clientを指定
             # 接続エラー等は無視するオプション
             - { ignore_error: true }
```
index名,type名も指定できますが、デフォルトだとそれぞれ`monolog`,`record`になるようです。
詳しくは[本家](https://github.com/Seldaek/monolog/blob/master/src/Monolog/Handler/ElasticSearchHandler.php#L54)を参照。

## symfony側でterminate eventをlistenする
ここで一旦、ロギングする側の設定へ移ります。

方針としては、
1. EventListenerでロガーを仕込む
2. 出力先に`ElasticSearchHandler`を登録する

どのEventに引っ掛けるか迷ったのですが、Responceを返した後が良いだろうということで`TERMINATE`Eventに対してEventListenerを作ります。

```AccessLogListener.php
<?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登録。

```service.yml
    # 自作の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のハンドラを登録します。

```config.yml
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/`

<img width="1270" alt=" 2017-12-03 22.12.29.png" src="https://qiita-image-store.s3.amazonaws.com/0/23189/31f0113b-d5fc-5b1c-d09d-cb8d3e05100b.png">

…おおお！?なんかそれっぽいぞ！?

## いろいろ放り込んでみる
今はURIのみしか取得してないので、EventLisnterでもっと情報を盛り込んでみます。
あんまり考えずいろいろ雑に突っ込みます。

```AccessLogListener.php
    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);
    }
```

結果

<img width="982" alt=" 2017-12-03 22.35.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/23189/3829a4f3-fa99-1666-0f65-a818d8fbe2c4.png">

…うおぉ！？めっちゃいろいろ入ってる！（自分で入れたんだからそりゃそうなんですが）

## 集計してみる
とりあえずリクエストの情報が逐一入ってることは確認できました。
ここから、あるURIへのアクセス数の日時集計を集計してみます。

集計クエリについての細かい説明は省きますが、早速日時集計をDev toolsからリクエストしてみます。

 ※デフォルトだと`index名:monolog`,`type名:record`なのでこんな感じ
`GET monolog/record/_search`

```json
{
  "size": 0,
  "query": {
    "match": {
      "message": "/target/uri" 
    }
  },
  "aggs": {
    "by_date": {
      "date_histogram": {
        "field": "datetime",
        "interval": "day",
        "format": "yyyy-MM-dd",
        "time_zone": "Japan"
      }
    }
  }
}
```

結果

```json
{
  "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`を追加してみます。

```json
  "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"
          }
        }
      }
    }
  }
```

結果

```json
{
  "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でレコード登録しない処理を挟む方が良いかと。

```AccessLogListener.php
    public function onKernelResponse(PostResponseEvent $event)
    {
        $request = $event->getRequest();
        $uri = $request->getRequestUri();

　　　　　　　　　　　　　　　　// ...

        // 集計対象にしないuriは省く
        if ($this->isIgnored($uri)) {
            return;
        }
        $this->logger->info($uri);
    }    
}
```

## UUの取得
こちらも今後の対応ですが、現状単なるPVしか取れていないので、ユニークユーザ数等の処理ができておりません。

未検証なため多分ですが、

1. リクエスト毎のsession等、ユーザを特定できるパラメータをレコードに含める
    - Monologの[Processer](http://symfony.com/doc/current/logging/processors.html)使えばできそう
- ESで[Cardinality Aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/6.0/search-aggregations-metrics-cardinality-aggregation.html)（SQLでの`group by`的なやつ）を使って、session毎のユニーク数をとる

あたりを使えば対応できそうかな？と考えています。

# まとめ

というわけで。
Monolog + ElasticSearchの組み合わせでアクセス集計基盤を作った話を紹介しました。
まだまだ対応できていないこともありますが、拾える情報のカスタマイズやESでの集計処理など、GAでは手の届かない部分での拡張性を感じております。
このようなこともできるよ、という一例になれば幸いです。