awkとuniqがあればコマンドラインで軽い集計できる話（追記：jqで改良）

下記のフォーマットで出力されるアクセスログからレスポンスタイムだけ取り出して集計したくなったときに、コマンドラインでできたよ、という話です。

```
2023-06-04T01:31:26+09:00	apache.access.hoge	{"host":"XX.XX.XX.XX","user":"-","method":"GET","path":"/foo/baa","protpcol":"HTTP/1.1","status":200,"size":471,"referer":"https://example.com/?aaa=bbb","agent":"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1","cookie":{"object1":{"tag":"hoge"}},"set_cookie":"-","authorization":"-","response_time":408417,"time":1685809886,"url":{"path":"/foo/baa"},"hostname":"sugoi-host-1"}
```
これが1アクセスごとに1行ずつ出力されたものが複数のgzipになってるので、全部読み込んで集計します。

## 集計ワンライナー:dog: 
```bash
$ gzcat access_log.* | awk -F 'response_time\":' '{print $2}' | awk -F ',' '{print $1}' | awk '{if($1 <= 700) print "under700"; else if($1 <= 1000) print "under1000"; else if($1 <= 2000) print "under2000"; else if($1 <= 3000) print "under3000"; else if($1 <= 4000) print "under4000"; else print "over4000";}' | sort | uniq -c
```
最後の `uniq` は `awk` で書くことも可能です。

これを実行すると、こんな感じで出力してくれます。
```text
 314 over4000
  56 under1000
 652 under2000
  98 under3000
  45 under4000
4396 under700
```

## 解説
上記のワンライナーでやっていることを前方から順番に説明していきます。

### 1) gzcat(zcat)で対象のファイルを読み出しawkに渡す
```bash
gzcat access_log.*
```
awkコマンドは何か入力を受け取って、その文字列対して処理をするコマンドなので、処理対象のファイルを最初に回答してパイプで渡しています。
圧縮されていないテキストファイルの状態であれば、awkコマンドの最後にファイルを指定してやればよいようです。

ちなみに通常Linuxだとzcatでgzipファイルを開いて出力しますが、今回操作した環境がMacだったのでzcatが使えずgzcatを使用しています。

### 2) awkでレスポンスタイムの列より後をまるごと取り出す
```
awk -F 'response_time\":' '{print $2}'
```
awkコマンドは入力文字列を何らかの区切り文字で分割し、列に対する操作ができるコマンドで、`-F`オプションで任意の区切り文字を指定できます。

カンマ区切りのログなので、本当はカンマを区切り文字として目的の列を取り出すのが素直な操作ですが、
取り出したい列よりも前で登場するカンマの数が異なる場合があり、列番号が行ごとに変わってしまうということが起きていました。
そこで「"response_time":」は1行に1回しか登場せず、その後ろに必ず必要な値が入っているということで、
区切り文字を`'response_time\":'`と指定しています。

そしてほしい値は区切り文字の後ろにあるので、`{print $2}`で必要な箇所だけを取り出しています。

ここまでで以下のような文字列を取り出してくれます。
```text
408417,"time":1685809886,"url":{"path":"/foo/baa"},"hostname":"sugoi-host-1"}
```

### 3) awkでレスポンスタイムの値だけを取り出す
```bash
awk -F ',' '{print $1}'
```
一つ前の手順と同じく、区切り文字を指定して`408417`の部分だけを取り出して、レスポンスタイムの値を集計できるように加工します。
必ず`数値,"time`となるので、最初二登場するカンマよりも前が取り出せればOK。
ということで区切り文字はカンマで、プリントさせるのは1列目。

ここまでで以下のような文字列だけになります。
```text
408417
```

### 4) awk内でif文を使って分類
```bash
awk '{if($1 <= 700) print "under700"; else if($1 <= 1000) print "under1000"; else if($1 <= 2000) print "under2000"; else if($1 <= 3000) print "under3000"; else if($1 <= 4000) print "under4000"; else print "over4000";}'
```
やっていることはbashでif文を使うのと同じです。

一つ前の手順までで`$1`にはレスポンスタイムの値だけが入ってくるようになっているので、それを順番にif-elseで評価して分類していきます。
`if($1 <= 700) なんか処理; else if($1 <= 1000)`とすることで、700以下の値、700より大きく1000以下の値…というふうに分類させます。

`print "under700"`といったprintは、分類した結果に基づいてそれぞれ数をかぞえたいので各カテゴリのラベルを出力させています。
どこに分類されたか分かればいいので、A/B/C/Dとかでもいいです。

ここまでで下記のような
```text
under2000
under2000
under700
under700
under1000
under700
under700
```

### 5) uniqで分類した結果の数を数える
```bash
sort | uniq -c
```
`uniq -c`で、入力ファイル内で重複する文字列を数えてくれます。
ただしこのとき、入力はソート済みでないと正しく集計が行えないため`sort`を挟んでから集計させます。
このソート処理がなかなかに重いため、集計する行数が膨大な場合はsort&uniqではなくawkを使用すると良いようです。([こちら](https://qiita.com/oshiro/items/11b6833aa41627723059)で解説されていました。)

こうすると一つ前の手順でつけたラベルごとに数を数えて、以下のように出力してくれます。
完成パチパチです:clap::tada::clap::tada: 
```text
 314 over4000
  56 under1000
 652 under2000
  98 under3000
  45 under4000
4396 under700
```

## 注意: 区切り文字の指定には気をつけましょう
適切に指定しないと、「思ったのと違う」ということが起きるので注意です。
今回でいうとユーザーエージェントの中にカンマが入っていたりいなかったりして、素直にカンマ区切りではほしい列が正しく取り出せないということがありました。

もともと決まった区切り文字で記述されたcsvファイルなんかを操作する場合は問題ないですが、
今回のアクセスログのように、列の中身が自由な文字列になりうる場合は注意が必要です。

# 2023/06/11追記 jqコマンドを使うバージョン
[区切り文字問題](https://qiita.com/masaozi3/items/81d109a10d1c4a025bd5#%E6%B3%A8%E6%84%8F-%E5%8C%BA%E5%88%87%E3%82%8A%E6%96%87%E5%AD%97%E3%81%AE%E6%8C%87%E5%AE%9A%E3%81%AB%E3%81%AF%E6%B0%97%E3%82%92%E3%81%A4%E3%81%91%E3%81%BE%E3%81%97%E3%82%87%E3%81%86)のせいで行内から必要な値を取り出すために[awkでレスポンスタイムの列より後をまるごと取り出す](https://qiita.com/masaozi3/items/81d109a10d1c4a025bd5#2-awk%E3%81%A7%E3%83%AC%E3%82%B9%E3%83%9D%E3%83%B3%E3%82%B9%E3%82%BF%E3%82%A4%E3%83%A0%E3%81%AE%E5%88%97%E3%82%88%E3%82%8A%E5%BE%8C%E3%82%92%E3%81%BE%E3%82%8B%E3%81%94%E3%81%A8%E5%8F%96%E3%82%8A%E5%87%BA%E3%81%99)としていますが、
基本的にjsonなので[jqコマンド](https://jqlang.github.io/jq/)を使えばもう少しスマートにできるのでは？ということで、改良してみました。

## 改良版ワンライナー:dog::sparkles: 
```bash
$ gzcat access_log.* | sed -e "s/^[0-9]...-[0-9].-[0-9].T[0-9].:[0-9].:[0-9].+09:00\tapache.access.hoge\t{/{/" | jq '.response_time' | awk '{if($1 <= 700) print "under700"; else if($1 <= 1000) print "under1000"; else if($1 <= 2000) print "under2000"; else if($1 <= 3000) print "under3000"; else if($1 <= 4000) print "under4000"; else print "over4000";}' | sort | uniq -c
```
元々のコマンドから
2) awkでレスポンスタイムの列より後をまるごと取り出す
3) awkでレスポンスタイムの値だけを取り出す
の箇所を改良しています。

## 解説
### sedで行頭のjsonじゃない奴らの排除
処理させたいファイルを見ると、各行の先頭に下記のような感じでjson形式を崩す文字列が入っていました。
```
2023-06-04T01:31:26+09:00	apache.access.hoge	{"host":"XX.XX.XX.XX","user":"-",...
```
この`2023-06-04T01:31:26+09:00	apache.access.hoge	`の部分がjson形式ではないので、jqコマンド実行時に次のようなパースエラーが発生します。
`parse error: Invalid numeric literal at line 1, column 14`

今回はこの部分は必要ないので、sedコマンドを使って不要な箇所を削ってしまいます。
```bash
sed -e "s/^[0-9]...-[0-9].-[0-9].T[0-9].:[0-9].:[0-9].+09:00\tapache.access.hoge\t{/{/"
```
ISO8601形式の日本時間の日時で始まって、タブ区切りで`apache.access.hoge`という文字列と`{`が続く部分を探して`{`だけに置き換えています。

これで下記のように正しくjson形式になっている部分だけになりました。
```
{"host":"XX.XX.XX.XX","user":"-","method":"GET","path":"/foo/baa","protpcol":"HTTP/1.1","status":200,"size":471,"referer":"https://example.com/?aaa=bbb","agent":"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1","cookie":{"object1":{"tag":"hoge"}},"set_cookie":"-","authorization":"-","response_time":408417,"time":1685809886,"url":{"path":"/foo/baa"},"hostname":"sugoi-host-1"}
```

### レスポンスタイムの値を取り出す
```bash
jq '.response_time'
```
オブジェクトの中から`response_time`を取ってきてね、というだけです。
特にオブジェクトの中でネストされたりもしていないので、指定は`.response_time`だけで良いです。

すると無事下記のようにレスポンスタイムの値だけを取り出すことができました。
```text
408417
```

あとは元々のコマンドと同様に、この値を分類してラベル付け→ソートして同じラベルのものの数を数えるとすることで、同じ結果が得られます。

こちらのほうが、区切り文字に左右されたりせず確実に必要な内容を取ってくれるので良さそうですね。
（元々のコマンドだと一応、「じゃあ今度は`response_time":`って記述が偶然他のフィールドの値に含まれちゃってたらどうするの？」問題が起きかねない。）

-----
### 参考
* https://atmarkit.itmedia.co.jp/ait/articles/1706/02/news017.html
* https://bioinfo-dojo.net/2017/10/04/awk-if/