事案報告では、イベントの発生状況を視覚的にわかりやすく表現したいものです。このとき私がけっこう使う図解として、横軸に時間を、縦軸にアセットをとって、発生したイベントをプロットする方法があります。
実例を出すことはできないので、ここでは ElasticのサンプルApacheログから、 /robots.txtへのアクセスを図解してみることにします。単に図解の材料として手ごろだっただけで、実用的な意味はありません。
ApacheのCombinedログを取得する関数read_combined()を定義し、変数log_rawに読み込みます。
library(tidyverse)
read_combined <- function(file) {
names <- c("ip_address", "remote_user_ident", "local_user_ident", "timestamp",
"request", "status_code", "bytes_sent", "referer", "user_agent")
col_types <- list(col_character(), col_character(), col_character(),
col_datetime("%d/%b/%Y:%H:%M:%S %z"), col_character(),
col_character(), col_integer(), col_character(), col_character())
data <- read_log(file = file, col_names = names, col_types = col_types)
return(data)
}
src_url <- "https://github.com/elastic/examples/blob/master/Common%20Data%20Formats/apache_logs/apache_logs?raw=true"
log_raw <- read_combined(src_url)
log_raw %>%
slice_head(n = 5)
## # A tibble: 5 x 9
## ip_address remote_user_ident local_user_ident timestamp request
## <chr> <chr> <chr> <dttm> <chr>
## 1 83.149.9.216 <NA> <NA> 2015-05-17 10:05:03 GET /pres~
## 2 83.149.9.216 <NA> <NA> 2015-05-17 10:05:43 GET /pres~
## 3 83.149.9.216 <NA> <NA> 2015-05-17 10:05:47 GET /pres~
## 4 83.149.9.216 <NA> <NA> 2015-05-17 10:05:12 GET /pres~
## 5 83.149.9.216 <NA> <NA> 2015-05-17 10:05:07 GET /pres~
## # ... with 4 more variables: status_code <chr>, bytes_sent <int>,
## # referer <chr>, user_agent <chr>
IPアドレス単体では情報が足りないので、AS情報を付与しましょう。関数make_routeview()は、ASのフルルートを取得して、あとでIPアドレスに対して割り当てられるように基数木(rv_trie)をつくります。次の関数asnames_current()は、 AS番号と名前との最新の組を取得するものです。
make_routeview <- function() {
rv_df <- astools::routeviews_latest()
rv_trie <- astools::as_asntrie(rv_df)
return(rv_trie)
}
rv_trie <- make_routeview()
asnames <- astools::asnames_current()
asnames %>%
slice_head(n = 5)
## # A tibble: 5 x 4
## asn handle asinfo iso2c
## <chr> <chr> <chr> <chr>
## 1 1 LVLT-1 LVLT-1 US
## 2 2 UDEL-DCN UDEL-DCN US
## 3 3 MIT-GATEWAYS MIT-GATEWAYS US
## 4 4 ISI-AS ISI-AS US
## 5 5 SYMBOLICS SYMBOLICS US
まずは何も考えずにプロットしてみます。横軸を時刻、縦軸をAS番号としましょう。
log_raw %>%
filter(str_detect(request, "/robots.txt")) %>%
mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>%
ggplot(mapping = aes(x = timestamp, y = asn)) +
geom_point()
これを改善していくことにします。
データについては、
あたりが思い浮かびます。
log_raw %>%
filter(str_detect(request, "/robots.txt")) %>%
mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>%
mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>%
group_by(asn) %>%
mutate(first_seen = min(timestamp)) %>%
ggplot(mapping = aes(x = timestamp,
y = reorder(asn, desc(first_seen)),
color = is_bot)) +
geom_point(alpha = 0.5, size = 2)
is_botとfirst_seenという、2つの変数を追加しました。
is_botはUserAgentに「bot」が含まれるかどうかで判定しているので、まともなものではありません。きちんとやるなら、 Crawler User Agentsと照合するなどすべきでしょう。
first_seenにはtimestampの最小値を当てていますが、その前にgroup_by(asn)していることが肝になっています。これによって、ASごとにtimestampの最小値が割り当てられているわけです。もしgroup_by()していなかったら、データフレーム全体の最小値(すなわち最初の行のtimestamp)が入ってしまいます。
そしてggplot()において、yにreorder(asn, desc(first_seen))として並び替えの指定を行っています。さらに、color属性としてis_botを加えてあります。あと、おまけで透明度を0.5としました。こうしておけば、プロットが重なると濃く表現されます。サイズも少し大きくしておきました。
横軸の改善点は、次の通りです。
library(scales)
log_raw %>%
filter(str_detect(request, "/robots.txt")) %>%
mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>%
mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>%
group_by(asn) %>%
mutate(first_seen = min(timestamp)) %>%
ggplot(mapping = aes(x = timestamp,
y = reorder(asn, desc(first_seen)),
color = is_bot)) +
geom_point(alpha = 0.5, size = 2) +
scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
minor_breaks = breaks_width("6 hours", offset=-9*60*60),
labels = label_date(format = "%d(%a)",
tz = "Asia/Tokyo")) +
labs(x = "2015年5月(JST)")
scale_x_datetime()で、軸の大分割・小分割・見出しを設定します。 timestampはPOSIXct型として持っているので、UTCかJSTかは「見た目」の問題であってデータの話ではありません。label_date()でタイムゾーンを設定するだけ…… であれば単純なのですが、このとき分割線が追随してくれないので、 breaks_witdhにoffsetを入れています。
同じように縦軸にも手を入れてみます。
log_raw %>%
filter(str_detect(request, "/robots.txt")) %>%
mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>%
mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>%
group_by(asn) %>%
mutate(first_seen = min(timestamp)) %>%
left_join(asnames) %>%
mutate(as_iso2c = str_c("AS", asn, " (", iso2c, ") ")) %>%
ggplot(mapping = aes(x = timestamp,
y = reorder(as_iso2c, desc(first_seen)),
color = is_bot)) +
geom_point(alpha = 0.5, size = 2) +
scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
minor_breaks = breaks_width("6 hours", offset=-9*60*60),
labels = label_date(format = "%d(%a)",
tz = "Asia/Tokyo")) +
labs(x = "2015年5月(JST)", y = "")
数字の羅列ではさみしいので、「AS」を接頭辞としてつけて、カッコ内に各ASの国コードを入れることにしました。それがas_iso2cとある部分です。また、縦軸のラベルは見ればわかるので消しています。
ところで1つ、NAになっているASがあります。ASNが「208722_13238」となっていた部分です。これはどちらもYANDEXなので、ちょっと細工しておきましょう。
asn_spec <-
tribble(~asn, ~handle, ~asinfo, ~iso2c,
"208722_13238", "YANDEX", "YANDEX", "RU")
asnnames_1 <-
asnames %>%
bind_rows(asn_spec)
log_raw %>%
filter(str_detect(request, "/robots.txt")) %>%
mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>%
mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>%
group_by(asn) %>%
mutate(first_seen = min(timestamp)) %>%
left_join(asnnames_1) %>%
mutate(as_iso2c = str_c("AS", asn, " (", iso2c, ") ")) %>%
ggplot(mapping = aes(x = timestamp,
y = reorder(as_iso2c, desc(first_seen)),
color = is_bot)) +
geom_point(alpha = 0.5, size = 2) +
scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
minor_breaks = breaks_width("6 hours", offset=-9*60*60),
labels = label_date(format = "%d(%a)",
tz = "Asia/Tokyo")) +
labs(x = "2015年5月(JST)", y = "")
その他の部分も手を入れます。
log_raw %>%
filter(str_detect(request, "/robots.txt")) %>%
mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>%
mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>%
group_by(asn) %>%
mutate(first_seen = min(timestamp)) %>%
left_join(asnnames_1) %>%
mutate(as_iso2c = str_c("AS", asn, " (", iso2c, ") ")) %>%
ggplot(mapping = aes(x = timestamp,
y = reorder(as_iso2c, desc(first_seen)),
color = is_bot)) +
geom_point(alpha = 0.5, size = 2) +
scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
minor_breaks = breaks_width("6 hours", offset=-9*60*60),
labels = label_date(format = "%d(%a)",
tz = "Asia/Tokyo")) +
labs(x = "2015年5月(JST)",
y = "",
title = "/robots.txtへのAS別アクセス状況",
caption = src_url) +
theme(
legend.position = "top",
legend.justification = c("right", "top"),
legend.title = element_text(size = 9),
legend.direction = "horizontal",
legend.box.spacing = unit(0, "pt"),
legend.margin = margin(2, 2, 2, 2, "pt")) +
scale_color_hue(name = "UAに「bot」を", labels = c("TRUE" = "含む", "FALSE" = "含まない")) +
guides(color = guide_legend(reverse = TRUE))
凡例の配置は、実際の描画状況に応じて調整します。たとえば今回は theme()内において legend.position = “top”としてプロット領域の上に置いています。 legend.position = c(0.99, 0.99)とすると、プロット領域の内側(右上端ギリギリ)に配置されます。
凡例の文字列をscale_color_hue()で指定しています。 hueはggplot2の標準のカラーパレットの名前です。そのまま凡例を並べるとFALSE/TRUEの順になるので、最終行で逆転させています。
この手の図解で縦軸に使うアセットとしては、ASNのほかにIPアドレスやホスト名、ユーザーアカウントなどが挙げられます。
また、今回は元データが1万行しかなかったので生データを使いましたが、数千万行になってくるとプロットに時間を要します。そういうときは、first_seenとlast_seenとの2点だけ出して線でつなげるとか、一定時間ごとに集計した時系列データをつくって密度を表示させるなどの工夫をします。下は一例です。
log_raw %>%
filter(str_detect(request, "/robots.txt")) %>%
mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>%
group_by(asn) %>%
mutate(first_seen = min(timestamp),
last_seen = max(timestamp)) %>%
distinct(asn, first_seen, last_seen) %>%
ggplot() +
geom_point(mapping = aes(x = first_seen, y = reorder(asn, desc(first_seen)))) +
geom_point(mapping = aes(x = last_seen, y = reorder(asn, desc(first_seen)))) +
geom_linerange(mapping = aes(xmin = first_seen, xmax = last_seen,
y = reorder(asn, desc(first_seen))))
以上、ggplot2を使って、横軸に時刻を、縦軸にアセットをとるグラフを描きました。一見すると複雑で、まあ実際単純ではないのですが、規則を理解すれば誰でも描けます。 ggplot2がグラフィックス文法に則っており、一貫した構文体系を提供しているからです。