ウェブ管理者の気まぐれ自作アクセス解析 ElasticsearchとKibanaを添えて

アクセス解析ツールmatomo

Google Analyticsは、ユーザーのオンライン行動やトラフィックの詳細な解析を提供することができますが、プライバシー上の懸念やセキュリティ上の問題があるため、欧州では排除の動きが活発化しています。Google Analytics以外にも多くのアクセス解析/トラフィック解析ツールがありますが、特にGoogle Analyticsが欧州で他のアクセス解析ツールより嫌われる原因は、次のような理由が挙げられます。

  1. プライバシー保護に対する意識の高まり:
    欧州では、個人データの保護に対する意識が高く、個人データの取り扱いに関する法律が厳しく定められています。Google Analyticsは、ユーザーのIPアドレスやブラウザ情報、検索履歴などの個人データを収集するため、個人データの保護に対する懸念が高まっています。
  2. Googleの優越性に対する不信感:
    Googleは、世界中で最も支配的なオンライン企業の1つであり、その多様なサービスや製品を提供するため、広範囲にわたる個人データを収集しています。欧州では、このようなデータ収集に対する不信感が高まっており、Google Analyticsの使用に対する抵抗感が生まれています。
  3. EUの一般データ保護規則(GDPR)への準拠問題:
    GDPRは、欧州の企業に対して、個人データの収集、保管、処理、共有に関する厳しい基準を設けています。Google Analyticsは、GDPRに準拠するための適切な対策を講じる必要がありますが、これが不十分であるとみなされると、企業は罰金などの厳しい制裁を受ける可能性があります。
  4. オープンソースやプライバシーに配慮したアクセス解析ツールの増加:
    欧州では、Google Analytics以外のオープンソースやプライバシーに配慮したアクセス解析ツールが増えています。これらのツールは、GDPRに準拠しやすく、ユーザーの個人データを最小限に抑えることができるため、Google Analyticsに対する代替手段として使用されることがあります。

Google Analyticsは、無料で利用できるアクセス解析ツールで、データの詳細な分析が可能でレポートのカスタマイズ性が高い特徴があるのでウェブサイトオーナーにとって有用です。しかし、Google Analyticsはページ表示を遅らせる原因でもあります。Google Analyticsを遅延処理することでページの表示遅延を改善することは可能ですが取得するデータの正確性が低下する可能性があります。

アクセス解析ツールmatomo
「がとらぼ」は長年にわたり、ウェブアクセス解析にmatomo (旧名Piwik)を使用していました。しかし、2022年夏頃に問題が発生し、全データを削除して再インストールしましたが、秋に再び不調になり、再々インストールすることになりました。その後、2022年末か2023年明け頃には新しいバージョンへの更新ができなくなり、2023年3月にはmatomo上でビジットログを見ることができなくなりました。再インストールしても短期間で利用不能になるため、matomoの代替を探すことになりました。

そこで、2022年秋からGoogle Analyticsをmatomoと並用することにしました。ただし、Google Analyticsは非常に強力なツールである一方で、レポートの作成方法が初心者には非常にわかりにくく、「初心者が扱いにくいツール」といえます。一般人が使用する場合は、もう少し簡単なツールが望ましいです。また、欧州では排斥され始めており、欧州以外の国でもGDPRのような規則が採用される可能性があるため、Google Analyticsの使用が避けられるようになるかもしれません。

幸いにも、アクセス解析ツールには、このページの最初の画像にあるサイト isgoogleanalyticsillegal.com で紹介されている、十分に実用的な自己ホスティングのツールを含む、いくつかの選択肢があります。

ただし、今回は自分でアクセス解析ツールを作成することに決めました。(2023年1月)

自作アクセス解析ツールの仕組み

  1. ウェブページにJavascriptを置き、そのスクリプトで閲覧者のブラウザ情報を収集します
  2. ウェブサーバにPHPスクリプトを設置し、JavascriptはそのPHPスクリプトにデータを送ります
  3. PHPスクリプトは送信されたデータに閲覧者のIPアドレスなどを追加しElasticsearchに送信します
  4. Elasticsearchではデータに含まれるIPアドレスからgeoipによるネットワーク的位置を取得し追加します
  5. Kibanaでデータを可視化します

ウェブページに設置するJavascript

javascriptで収集する情報

  • ユーザーエージェント: window.navigator.userAgent
  • ユーザーの言語: language: window.navigator.language
  • ブラウザ画面横幅: screenWidth: window.screen.width
  • ブラウザ画面高さ: screenHeight: window.screen.height
  • プラットフォーム: platform: window.navigator.platform
  • ページタイトル: document.title
  • ページURL: window.location.href
  • ウェブサーバ(ホスト/ドメイン): location.hostname
  • リファラ: getResponseHeader("Referer")

取得するデータの種類は欲張らないことにしました。ページタイトル,ページURL,ウェブサーバのホスト名に至ってはユーザーの情報ですらありません。
リファラ(直前に表示していたページ)はwindow.document.referrerで取得する方が簡単でしょうが、今回は敢えて手間がかかるgetResponseHeader("Referer")で取得してみます。
getResponseHeader("Referer")を使う場合、HTTPヘッダーを直接取得するのでブラウザの挙動の違いが少なめ(ただし全てのブラウザで機能するとは限らない)、CORSの影響を受けます。window.document.referrerと併用するのもアリかもしれませんが、モダンブラウザはリファラが取りにくいものなので拘らないことにします。

リファラをwindow.document.referrerで取る場合
<script>
function collectInfo() {
    const browserInfo = {
        userAgent: window.navigator.userAgent,
        language: window.navigator.language,
        screenWidth: window.screen.width,
        screenHeight: window.screen.height,
        platform: window.navigator.platform,
        referrer: window.document.referrer,
        title: document.title,
        url: window.location.href,
        host: location.hostname,
    };
    // ブラウザの情報をサーバに送信するためのリクエストを生成する
    const xhr = new XMLHttpRequest();
    xhr.open('POST', 'https://送信先URL/collect.php', true);
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.send(JSON.stringify(browserInfo));
}
// 1000ミリ秒の遅延実行でcollectInfo関数呼ぶ
setTimeout(collectInfo, 1000);
</script>

とてもシンプルです。

上のコードをウェブページ設置用にミニファイします。(下)

<script>
function collectInfo(){let e={userAgent:window.navigator.userAgent,language:window.navigator.language,screenWidth:window.screen.width,screenHeight:window.screen.height,platform:window.navigator.platform,referrer:window.document.referrer,title:document.title,url:window.location.href,host:location.hostname},t=new XMLHttpRequest;t.open("POST",'https://送信先URL/collect.php',!0),t.setRequestHeader("Content-Type","application/json"),t.send(JSON.stringify(e))}setTimeout(collectInfo,1e3);
</script>
リファラをgetResponseHeader("Referer")で取る場合
<script>
// 参照元ページのURLを取得する関数
function getReferrer(callback) {
  var xhr = new XMLHttpRequest();
  xhr.open("GET", window.location.href);
  xhr.send();
  xhr.onload = function() {
    var referrer = xhr.getResponseHeader("Referer");
    callback(referrer); // コールバック関数に参照元ページのURLを渡す
  };
}

// ユーザーエージェント、言語設定、画面サイズ、プラットフォーム、ページタイトル、ページURL、ホスト名を取得する関数
function getOtherData() {
  var userAgent = navigator.userAgent; // ユーザーエージェント
  var language = navigator.language; // 言語設定
  var screenWidth = window.screen.width; // 画面幅
  var screenHeight = window.screen.height; // 画面高さ
  var platform = window.navigator.platform; // プラットフォーム
  var title = document.title; // ページタイトル
  var url = window.location.href; // ページURL
  var hostname = location.hostname; // ホスト名
  return {userAgent: userAgent, language: language, screenWidth: screenWidth, screenHeight: screenHeight, platform: platform, title: title, url: url, hostname: hostname}; // オブジェクトとして返す
}

// 取得したデータを送信する関数
function sendData(data) {
  var xhr = new XMLHttpRequest();
  xhr.open("POST", "https://送信先URL/collect.php"); // 送信先のURL
  xhr.setRequestHeader("Content-Type", "application/json"); // データの形式をJSONに指定
  xhr.send(JSON.stringify(data)); // データをJSONに変換して送信
}

// メインの処理
getReferrer(function(referrer) { // 参照元ページのURLを取得する
  var data = getOtherData(); // ユーザーエージェント、言語設定、画面サイズ、プラットフォーム、ページタイトル、ページURL、ホスト名を取得する
  data.referrer = referrer; // 参照元ページのURLをデータに追加する
  sendData(data); // データを送信する
});

// 1000ミリ秒の遅延実行でgetReferrer関数呼ぶ
setTimeout(getReferrer, 1000);
</script>

上のコードをウェブページ設置用にミニファイします。(下)

<script>
function getReferrer(e){var t=new XMLHttpRequest;t.open("GET",window.location.href),t.send(),t.onload=function(){e(t.getResponseHeader("Referer"))}}function getOtherData(){var e,t=navigator.userAgent,n=navigator.language,r=window.screen.width,a=window.screen.height,o=window.navigator.platform,i=document.title;return{userAgent:t,language:n,screenWidth:r,screenHeight:a,platform:o,title:i,url:window.location.href,hostname:location.hostname}}function sendData(e){var t=new XMLHttpRequest;t.open("POST","https://送信先URL/collect.php"),t.setRequestHeader("Content-Type","application/json"),t.send(JSON.stringify(e))}getReferrer(function(e){var t=getOtherData();t.referrer=e,sendData(t)});setTimeout(getReferrer,1e3);
</script>

リファラの取り方のどちらか好きな方を選択してウェブページの </body> 直前にでも置けば良いでしょう。

このページではsetTimeout()を使って遅延実行させていますが、scriptタグにdefer属性を指定してやれば自動的に遅延実行相当にすることもできます。
上の例では setTimeout(getReferrer, 1000); のように関数を呼ぶ部分を getRefferer(); に書き換え、<script> になっている部分を<script defer> にします。

なお、非同期にしたいからとasync属性を付けたがる人がいますが、それだと(非同期なので)想定外に早く実行されてしまい収集データが未定義状態で送信される可能性があるのでdeferにしましょう。

ウェブサーバでアクセスデータを受け取るPHPスクリプト

データ送信先のPHPスクリプトcollect.php
<?php
// Elasticsearchに情報を送信するための関数
function sendToElasticsearch($data) {
    // ElasticsearchのURLとポート番号を指定
    $elasticsearch_url = 'https://192.168.0.1'; //Elasticsearchの待受けURLに変更
    $elasticsearch_port = 9200;

    // Elasticsearchの認証情報を指定
    $username = 'elasticaccount'; //Elasticsearchにアクセスするアカウント
    $password = 'a1b2c3d4e5f6g7h8i9'; //Elasticsearchにアクセスするパスワード

    // ElasticsearchのCA証明書を指定
    $certificate_file = '/usr/local/www/hoge/http_ca.crt';

    // Elasticsearchへ送信するJSONデータ
    $timestamp = gmdate('Y-m-d\TH:i:s.v\Z'); //Elasticsearchに送るデータのタイムスタンプ (UTCで)
    //このスクリプトにアクセスしてきたIPアドレスを取得し配列に追加、上のタイムスタンプも配列に追加
    $dataWithIPTS = array_merge($data, array('ip' => $_SERVER['REMOTE_ADDR']), array('@timestamp' => $timestamp));
    $json_data = json_encode($dataWithIPTS); //データはJsonで

    // cURLセッションを初期化
    $ch = curl_init();

    // ElasticsearchにアクセスするURLを作成
    $index = 'accesslog-' . gmdate('Y.m.d');  //インデックスは access-2023.04.01 のような形式とする(日付変わりはUTCで)
    // _docは型なので指定忘れないよう
    //  ?pipeline=geoip-pipelineは後述のパイプラインの指定
    $url = "{$elasticsearch_url}:{$elasticsearch_port}/$index/_doc?pipeline=geoip-pipeline";

    // cURLオプションを設定    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $json_data);
    curl_setopt($ch, CURLOPT_HTTPHEADER, array(
        'Content-Type: application/json',
        'Authorization: Basic ' . base64_encode("$username:$password"),
    ));
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_CAINFO, $certificate_file);

    // ElasticsearchにJSONデータを送信
    $response = curl_exec($ch);

    // cURLセッションを閉じる
    curl_close($ch);

    return $response;
}

// 受信したJSONデータを解析する
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    //ブラウザ(Javascript)から情報を取得する
    $data = file_get_contents('php://input');
    $json = json_decode($data, true);
    //Elasticsearchに送信する
    sendToElasticsearch($json);
}
?>

Elasticsearch GeoIP用インジェストパイプラインの作成

最近のElasticsearchは標準でGeoipが入っているのでGeoipプラグインをインストールする必要はありません。
データのちょっとした加工にLogstashを挟むことが多いでしょうが、Geoipで座標追加する程度であればLogstashを使わずにElasticsearchに簡単なパイプラインで十分かと思います。

PUT _ingest/pipeline/geoip-pipeline
{
  "description" : "Add geoip info",
  "processors" : [
    {
      "geoip" : {
        "field" : "ip",
        "target_field" : "geoip"
      }
    }
  ]
}

Kibanaの「開発ツール」「コンソール」で上の12行をペーストして1行目にフォーカスしてその右の をクリックして実行します。これでgeoip-pipelineという名前のパイプラインが作成されます。
このパイプラインでは、フィールド名「ip」でIPアドレスが入ったデータからgeoip.locationフィールド下に緯度/経度、geoip.continent_nameに地域名(アジアなど)、geoip.country_nameに国名(Japanなど)、geoip.country_iso_codeにISOの国コード(JPなど)、geoip.region_nameに県名など、geoip.city_nameに都市名などの情報が追加されます。
パイプラインを作成しても使用を明示しなければ勝手には使用されません。先のPHPコードでURLクエリーの最後に指定したパイプラインがこれです。

Elasticsearch インデックスのマッピングの変更

緯度と経度の情報がデータに含まれればKibanaの地図で可視化できそうなものですが、実際には座標データが見つからないということで地図にプロットできません。座標データはgeoip.location下入りますが、普通にElasticsearchにデータを送信して自動でインデックスが作成されるとgeoip.locationのproperties下でlat(緯度),lon(経度)の型がfloatでマッピングされてしまいます。正常そうに思えますがKibanaの地図のプロット用として使うには全く違うようです。
geoip.locationにgeo_pointという型を指定するのが良いようですが、ここで注意しなければならないのは、accesslog-*インデックスにデータがある状態では型の変更ができないことです。
Kibanaの「開発ツール」「コンソール」で下の20行をペーストします。そして、1行目にフォーカスしてその右の をクリックして実行し、ただちに2行目にフォーカスしてその右の をクリックして実行します。

DELETE /accesslog-*
PUT /_template/accesslog
{
  "index_patterns": "accesslog-*",
  "mappings": {
    "properties": {
      "geoip": {
        "properties": {
           "location": {
             "type": "geo_point"
            },
            "ip": {
              "type": "ip"
            }
          }
        }
      }
    }
  }
}

12,13行目でついでにipフィールドの型をip (IPアドレスの型)に変更しています。自動でマッピングされるとIPアドレスの型がただのtextになってしまうので気持ち悪いですからね。

マッピングの確認
Kibanaのメニューから「スタック管理」「インデックス管理」「インデックス」タブを選択し、検索欄に「accesslog」を入力、インデックスリストからaccesslog-YYYYmmddのリンクをクリック。上部の「マッピング」タブを選択すると現在のインデックスに適用されているマッピングが表示されます。これで geoip.properties.locationの型がgeo_pointでipの型がipであれば指定したマッピングが正しく適用されています。

geoip.locationは下のようなデータが入るようになります。(マッピングではなく実データ)

    "geoip.location": [
      {
        "coordinates": [
          139.7539,
          35.6838
        ],
        "type": "Point"
      }
    ],

Elasticsearchは何をしなければならないかは判らないことが多いですが、やることは簡単です。

Kibanaで地図にプロットする

ビジュアライズでマップ作成 1
Kibanaのメニューから「Visualize Library」右上の「 Create ビジュアライゼーション」「マップ」をクリックします。

ビジュアライズでマップ作成 2
地図が表示されます。座標データをプロットする「レイヤーを追加」をクリックします。

ビジュアライズでマップ作成 3
プロット方法を選べます。今回はヒートマップを選択しました。

ビジュアライズでマップ作成 4
データビューの選択ドロップダウンメニューで「accesslog-*」を選択します。

ビジュアライズでマップ作成 5
プロット可能と認識される形式の座標データがインデックスに含まれるならクラスターフィールドにフィールドが自動選択される筈です。または、ドロップダウンメニューでgeoip.locationフィールドを選択します。選択したフィールドがプロット可能な座標データであると認められないと選択させて貰えません。そこで前述のマッピングの変更が必要となります。
右下の「レイヤーを追加」をクリックします。

ビジュアライズでマップ作成 6
必要に応じてメトリックのアグリゲーションを変更します。基本的には「カウント」で良いかと思われます。
右下の「 保存して閉じる」をクリックします。

ビジュアライズでマップ作成 7
右上の「 保存」をクリックします。 「マップを保存」ポップアップ画面で分かりやすいビジュアライズの名前を入力します。右側でビジュアライズを既存のダッシュボードに追加する新しいダッシュボードに追加するかダッシュボードに追加しないか選択します。「なし」でダッシュボードに追加しない場合でも後からダッシュボードの編集でこのビジュアライズを追加することができます。
右下の「保存してダッシュボードを開く」(表示される文字列は変わります)をクリックします。

ビジュアライズでマップ作成 8
画像はアクセスログのダッシュボード(作成中)に地図のビジュアライズを追加した状態です。
ヒートマップは表示にクセがあるようで、上の画像のような世界地図状態では日本とアメリカ中央とフロリダの3箇所しか光っていません。特に欧州には全く色の違いはないように見えます。(次)

ビジュアライズでマップ作成 9
表示データの条件を変えずに地図の欧州部分を拡大表示しました。イスタンブールが最もはっきり光っていますが、それ以外にも数箇所にぼんやりと色が付いています。つまり、地図を拡大しないと確認できないということです。これではひと目で情報を確認できないということになります。ヒートマップ以外のプロットを選択する方が良いかもしれません。

Elasticsearch + Kibanaではグラフや表は特に難しい部分はなく初見でも容易に作成できる筈ですが、IPアドレスを元に地図にプロットするのはコツがあることを知らないと難しい部分があるのでこの記事では地図にプロットする部分に注力しました。

2023年4月18日追記:
この記事では getResponseHeader("Referer") を使ってリファラを取ると書いておきながら、そのコードを貼り忘れていたので追加して修正しました。
併せてscriptタグにdefer属性を付ける場合についても追加しました。