nginxのログにユーザ別のアクセスカウンタを残す


やりたいこと

運用しているサイトではnginxのログにユニークなユーザID(uid)を残していてelasticsearchにもログを流し込んでいたので、kibanaでDAU(他にもHAU,WAU,MAU)を確認することは簡単にできていた。

ただDAUが「新規参加者+これまで来ていた人」と、まとまりすぎている感じがあり、下記のようにどのくらいの頻度でサイトに来てくれているかでユーザをグルーピングしてDAUを分解したものを見たくなった。

  • ほぼ毎日きてくれる人
  • 1日何回もログインしてる人
  • 初回参加のみの人

設定するもの

  1. nginxにアクセスが来る度にRedisに用意したユーザ別のアクセスカウンタを更新する。
  2. Redisからアクセスカウンタを読み取りnginxのアクセスログにカウント数を記録する。
  3. ElasticSearchにログを取り込みvisualizationで集計してDAUを細分化して表示する。

nginxで処理するとレスポンスタイムの低下が考えられるので本当はログをElasticSearchに流し込むタイミングでの処理するほうが良いとは思うが、今回はそれを許容した。

nginxの設定

ngx.location.captureやredisへの接続を使うことになったのでopenrestyを使う。

  • nginx.conf(部分抜粋)
json_log_fields main 'time_local'
              'x-uid'
              'cnt_hour'
              'cnt_dow'
              'cnt_day'
              'cnt_sec'

set $uid "unknown";
if ($x-uid ~ ([0-9a-z]+)) {
  set $uid $1;
}
strftime hour "%H"; # 時 0-23
strftime dow "%w" # 曜日 0-6
strftime day "%d"; # 日付 01-31
strftime sec "%S"; # 秒 01-59
set $cnt_hour "0";
set $cnt_dow "0";
set $cnt_day "0";
set $cnt_sec "0";
set $res "-";

location /update_counter {
  internal;
  redis2_raw_queries 2 "select 15\r\nevalsha 7d935b5da11543863d3de7e8af785052aebff453 1 $uid $sec $hour $dow $day\r\n";
  redis2_pass redis_server;
}

location /get_counter {
  internal;
  redis2_raw_queries 2 "select 15\r\nevalsha b1c165f798a1a3df36bb5f513a7a59efde68d941 1 $uid\r\n";
  redis2_pass redis_server;
}

location / {
  access_by_lua '
    ngx.location.capture("/update_counter")
    local res = ngx.location.capture("/get_counter")
    ngx.var.res = res.body
    local m, err = ngx.re.match(res.body, "([0-9]+):([0-9]+):([0-9]+):([0-9]+)")
    if m then
      ngx.var.cnt_sec = m[1]
      ngx.var.cnt_hour = m[2]
      ngx.var.cnt_dow = m[3]
      ngx.var.cnt_day = m[4]
    end
  ';
  limit_req zone=one burst=2;
  proxy_pass http://puma-app;
}

evalshaを使って後述のredisに登録したスクリプトを呼び出している。

  • /update_counter

    • uid別に持っているアクセスカウンタを更新する。
  • /get_counter

    • uid別に持っているアクセスカウンタを取得する。
  • /location

    • /update_counter /get_counterを実行してアクセスカウンタを$cnt_sec|hour|dow|dayに設定する(ログにそのまま出力される)

redisのカウンタ

TTL付きでアクセスカウンタをセットする

  • 直近一分のアクセス キー名: "$uid:sec:[0-59]" 値: 1
  • 直近24時間のアクセス キー名: "$uid:hour:[0-23]" 値: 1
  • 直近1週間のアクセス キー名: "$uid:dow:[0-6]" 値: 1
  • 直近30日のアクセス キー名: "$uid:day:[01-31]" 値: 1
script load "
 redis.call('setex', KEYS[1] .. ':sec:' .. ARGV[1], 60, 1)
 redis.call('setex', KEYS[1] .. ':hour:' .. ARGV[2], 86400, 1)
 redis.call('setex', KEYS[1] .. ':dow:' .. ARGV[3], 604800, 1)
 redis.call('setex', KEYS[1] .. ':day:' .. ARGV[4],2592000, 1)
"
7d935b5da11543863d3de7e8af785052aebff453

アクセスカウンタを読み出す

  • カウンタを読み出す

    • 戻り値

    直近1分以内のアクセス数:直近24時間以内のアクセス数:直近7日以内のアクセス数:直近30日以内のアクセス数
    (アクセス数は合計値ではなくて単位あたりのユニークカウントとする。例えば金曜日1回、土曜日100回アクセスしても1週間のアクセスは2)

script load "
 local sec = 0
 local hour = 0
 local dow = 0
 local day = 0
 local ret = nil
 for i = 0,59 do
   ret = redis.call('get', KEYS[1] .. ':sec:' .. string.format('%02d', i))
   if ret then
    sec = sec + 1
   end
 end
 for i = 0,23 do
   ret = redis.call('get', KEYS[1] .. ':hour:' .. string.format('%02d', i))
   if ret then
    hour = hour + 1
   end
 end
 for i = 0,6 do
   ret = redis.call('get', KEYS[1] .. ':dow:' .. string.format('%1d', i))
   if ret then
    dow = dow + 1
   end
 end
 for i = 1,31 do
   ret = redis.call('get', KEYS[1] .. ':day:' .. string.format('%02d', i))
   if ret then
    day = day + 1
   end
 end
 return string.format('%d:%d:%d:%d', sec, hour, dow, day)
"

b1c165f798a1a3df36bb5f513a7a59efde68d941
  • ユーザ1回のアクセスでredisの操作が120回以上発生しているのはまずいか。

kibanaでのvisualization

  • 例) 直近一週間のうち何回きてるかでDAUをグルーピングして表示する
    • Y-axis x-uidでunique count
    • X-axis タイムスタンプ
    • split bars -> Histgram -> cnt_dow -> interval 1