エムオーテックス株式会社が運営するテックブログです。

Elixirで運用監視ツールを書くときに工夫したこと

はじめに

こんにちは、LANSCOPEセキュリティーオーディター開発チームの菊森です。
最近サービスレベルの向上に向けた取り組みの一環で運用監視ツールをElixirで書きました。
今回、企画からリリースまでの一連の過程で考えたことや工夫したことをご紹介したいと思います。
また、役に立ったElixirの機能についても合わせてご紹介していきます。

なぜやったか

LANSCOPEセキュリティーオーディターは、ひとことで言うとMicrosoft365上の操作を見える化するサービスです。

LANSCOPEセキュリティーオーディターにはアラート通知機能というものがあります。
これはMicrosoft365でアラート対象となる操作が行なわれた際に、本人または管理者にビジネスチャットのチャットボットを通じて自動通知できるというものです。
この機能はエムオーテックス側のシステムだけでなく、ビジネスチャット側の障害やWeb API仕様変更にも影響されるため、このサーバーサイドの動作を監視する仕組みを強化することにしました。

あと、なぜElixirを用いたのかについても簡単に触れておきます。
Elixirは耐障害性の高いシステムの構築に適した言語で、安定稼働が求められるLANSCOPEセキュリティーオーディターのサーバーサイドのメイン言語として利用しています。
またElixirにはescriptというコマンドラインから呼び出すことのできる実行可能ファイルを手軽に作成する仕組みがありますので、ちょっとしたツールも作りやすいです。
自分もチームメンバーも使い慣れた言語を使えばサクッと作れてメンテも楽になると思い、今回用いることにしました。

なにをやったか

障害検知の方法として、まずはビジネスチャットのWeb APIを呼び出した際のエラー応答をフックして開発者に通知するという方法が思いつきます。
しかしながら、特に障害の際は予測のできない応答が返されることも多いため、この方法ではシグナルとノイズの区別が難しくなります。
また、エンドユーザー様の設定の誤りによってメッセージ投稿に失敗してしまうこともよく起こっています。
これらの状況を踏まえ、今回は開発者が定常時のトレンドを把握し、トレンドから外れた際に気がつけるような状態を目指すことにしました。

どうやったか

概要

LANSCOPEセキュリティーオーディターはチャットボットのメッセージ送信状況をデータベースで管理しています。
このデータベースからメッセージ送信に失敗したボットを抽出し、Chatworkに通知するescriptを今回作りました。
これをデータベースにアクセスできるUbuntuサーバーにデプロイし、このescriptを呼び出すコマンドをcronジョブに登録し定期実行するようにしました。

前提となる各技術要素のバージョン

今回前提としているOS、言語、ミドルウェアのバージョンは以下の通りです。

  • Ubuntu 20.04
  • Elixir 1.15.4
  • PostgreSQL 12.16
  • MySQL 8.0

ビジュアルの設計

まず、どのような情報をどのような見た目で投稿するかを設計しました。
エムオーテックスに導入されている独自AI対話システム「Smartばんにゃ」に訊いてみますと、いい感じの改善案を提示してくれました。とても助かります。
ビジュアルは特に大事なので、チームの同僚からも意見・感想をもらい、ブラッシュアップしていきました。
※下図に表示されている会社名は例示のための架空のものです。

エムオーテックスのChatGPTを活用した独自AIシステム「Smartばんにゃ」によるメッセージの改善案

同僚の意見・感想を取り入れブラッシュアップした後の姿

CLIの設計

つぎに、運用シーンを想像しながらCLIを設計しました。
運用時に調整したいパラメータはアプリケーションを再ビルドすることなく手軽に変更できると嬉しいので、これらをescriptの引数で渡せるようにしておきたいです。
今回は以下のパラメータをサポートすることにしました。

TOP_N (required)
メッセージ送信失敗件数が多い順に最大何件まで表示するかを数字で指定します。

THRESHOLD_FAILURE_COUNT (required)
メッセージ送信に何回以上失敗したボットを含めるかを数字で指定します。

TIME_WINDOW_MINUTES (required)
実行時刻から何分前までの期間に対して検証するかを時間 (分) で指定します。

EXCLUSION_CHAT_LINKAGE_SETTING_IDS (optional)
検証を除外したいチャット連携設定IDを指定します。スペース区切りで複数指定できるようにします。
こうすると問題が起きていることが既知のお客様の通知を一時的に抑制しノイズを減らすことができて便利です。

escriptの名前は「チャットボットを監視するアプリ」という意味を込めて端的にbot_watcherとしてみました。
そしてCLIを以下のように定義してみました。

使い方

./bot_watcher TOP_N THRESHOLD_FAILURE_COUNT TIME_WINDOW_MINUTES EXCLUSION_CHAT_LINKAGE_SETTING_IDS

利用例

特定のチャット連携設定2件を除き、60分のタイムウインドウで5回以上メッセージ送信失敗したボットの上位3件を通知する場合の利用例です。
※チャット連携設定IDはダミーデータです。

./bot_watcher 3 5 60 '0a6aa3df-03c1-4315-914b-5b4d1d2ce99f' 'fb9c2830-3b39-42bf-b1a0-b65b4e242a9a'

プロジェクトの作成

ElixirにはMixというビルドツールが付属しています。
以下のようにコマンドラインからmix newコマンドを実行することでプロジェクトを作成しました。

mix new bot_watcher

するとbot_watherというディレクトリが作られ、その中に以下のファイルが作られました。

* creating README.md
* creating .formatter.exs
* creating .gitignore
* creating mix.exs
* creating lib
* creating lib/bot_watcher.ex
* creating test
* creating test/test_helper.exs
* creating test/bot_watcher_test.exs

私の所属するチームではASDFというバージョンマネージャーを用い、チーム内の全員が同じバージョンのElixirを使用することを保証しています。
このプロジェクトもチームで共同開発できるようにしておきたいため、ASDFが有効となるように設定しておきます。
プロジェクトのルートディレクトリに.tool-versionsというファイルを作成し、以下のように利用バージョンを記載し、Git管理下に含めました。
なお、ElixirはErlang VM上で動作するため、Erlangのバージョンも合わせて指定しておきました。

elixir 1.15.4
erlang 25.3.2.4

プロジェクトのディレクトリ・ファイル構成

個々の具体的な実装の説明に入る前に、プロジェクトのディレクトリ・ファイル構成について説明しておきます。
全体像は以下の通りです。

bot_watcher/
├── config
│   └── runtime.exs
├── deps
├── lib
│   ├── adapter
│   │   └── chatwork_adapter.ex
│   ├── application.ex
│   ├── cli.ex
│   ├── core.ex
│   ├── domain_object
│   │   └── message_sending_info.ex
│   ├── main_repo.ex
│   ├── message_repo.ex
│   ├── repository
│   │   └── message_sending_info_repository.ex
│   └── service
│        └── message_sending_info_chatwork_notification_service.ex
├── mix.exs
└── mix.lock
  • config/runtime.exsにはツールの設定を記述しました。
  • depsにはMixによってプロジェクトの依存ライブラリーのソースコードが格納されます。
  • lib/adapter/chatwork_adapter.exには他のコードからChatworkのWeb APIを呼びやすくするためのコードを記述しました。
  • lib/application.exにはこのツールを構成するプロセス群の起動停止を管理するためのコードを記述しました。
  • lib/cli.exにはCLIの役割を担うコードを記述しました。
  • lib/core.exにはツールの全体的な処理手順を記述しました。
  • lib/domain_object/message_sending_info.exにはメッセージ送信情報を表すドメインオブジェクトを実装しました。
  • lib/main_repo.exlib/message_repo.exにはデータベースに接続するためのコードを記述しました。
  • lib/repository/message_sending_info_repository.exにはデータベースからメッセージ送信情報を取り出す処理を実装しました。
  • lib/service/message_sending_info_chatwork_notification_service.exにはチャットワークに投稿する内容を作成し投稿する処理を実装しました。
  • mix.exsにはプロジェクトの設定を記述しました。
  • mix.lockはMixによって生成され、すべての依存ライブラリーのバージョンが記録されています。

プロジェクトの設定

プロジェクトの設定はmix.exsファイルに記述します。今回は以下のように設定しました。

  • def application do ...の箇所のmodという項目に、アプリケーション起動時に呼び出すモジュールを指定しています。
  • defp escript do ...の箇所のmain_moduleという項目に、escriptの実行ファイルのエントリポイントとして扱うモジュールを指定しています。
  • defp deps do ...の箇所に依存ライブラリーを指定しています。

mix.exs

defmodule BotWatcher.MixProject do
  use Mix.Project

  def project do
    [
      app: :bot_watcher,
      version: "0.1.0",
      escript: escript(),
      dialyzer: dialyzer(),
      deps: deps()
    ]
  end

  def application do
    [
      mod: {BotWatcher.Application, []},
      extra_applications: [:logger]
    ]
  end

  defp escript do
    [main_module: BotWatcher.CLI]
  end

  defp dialyzer do
    [
      plt_core_path: "priv/plts",
      plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
    ]
  end

  defp deps do
    [
      {:typed_struct, "~> 0.3.0"},
      {:jason, "~> 1.0"},
      {:ecto_sql, "~> 3.0"},
      {:myxql, ">= 0.0.0"},
      {:postgrex, ">= 0.0.0"},
      {:httpoison, "~> 2.0"},
      {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
      {:dialyxir, "~> 1.3", only: [:dev], runtime: false}
    ]
  end
end

そして、コマンドラインから以下のコマンドを実行することで、mix.exsに定義した依存ライブラリーを取得します。

mix deps.get

今回指定した依存ライブラリーについてごく簡単に触れておきます。

  • typed_structはElixirの構造体とそのtypespecを簡潔に定義できるようにするためのライブラリーです。
  • jasonはJSONのパースと生成に用いるライブラリーです。
  • ecto_sqlはSQLデータベースを便利に操作するためのライブラリーです。今回はPostgreSQLとMySQLの両方を使う必要がありましたのでそれぞれのドライバー (postgrex, myxql) も指定しています。
  • httpoisonはHTTPクライアントで、ChatworkのWeb APIを呼び出してメッセージを投稿するために使用しています。
  • credoはElixirの静的コード解析ツールで、コーディングスタイルの一貫性を保つための助言をしてくれます。
  • dialyxirはtypespecの内容に基づいて静的型検証を行なうことができるツールです。このようにElixirでは任意のタイミングで静的型検証を追加できるため、立ち上げ時には高速開発し、立ち上げ後に徐々に堅牢性を高めていくことができています。

ドメインオブジェクトの実装

設計したビジュアルを見据えた上で、メッセージ送信情報を表すドメインオブジェクトを構造体で実装しました。
個々のフィールド名はビジュアルの各項目を端的に表現するように意識して設計しました(個々のフィールドの詳細説明は割愛させていただきます)。
このようにドメインオブジェクトを先に実装しておくと、他のプログラムを書く際に視線が安定するように感じます。
7行目あたりに@derive [Jason.Encoder]という奇妙な記述がありますが、これはjasonライブラリーでこの構造体をJSON文字列化できるようにするための記述です。
ツールの動作確認のためにCLIでメッセージ送信情報を出力しておきたく、またJSON形式がお手頃と思ったためこのように設定しました。
個々の構造体についてJSON文字列化のための関数を個別に実装する必要がなく、1行書くだけで既存の実装を再利用できるのがとても嬉しいです。

lib/domain_object/message_sending_info.ex

defmodule BotWatcher.DomainObject.MessageSendingInfo do
  @moduledoc """
  Domain object representing message sending information.
  """

  use TypedStruct

  @derive [Jason.Encoder]
  typedstruct do
    @typedoc "A message sending information"

    field(:company_id, String.t())
    field(:company_name, String.t())
    field(:bot_name, String.t())
    field(:chat_linkage_setting_name, String.t())
    field(:chat_code, String.t())
    field(:current_failure_count, integer())
    field(:failure_diff_compared_to_yesterday, integer())
    field(:failure_diff_compared_to_last_week, integer())
    field(:failure_diff_compared_to_2_weeks_ago, integer())
    field(:failure_diff_compared_to_3_weeks_ago, integer())
    field(:failure_diff_compared_to_4_weeks_ago, integer())
    field(:failure_diff_compared_to_5_weeks_ago, integer())
  end
end

コアプログラムの実装

次に今回のツールの主要な処理の流れを実装しました。ここではコアプログラムと呼ぶことにします。
すべての処理を1ファイルに書くことも可能ですが、私としてはある程度の単位で動作確認し即時でフィードバックを得ながらを全体を作り上げていくのがやりやすいため、動作確認しやすい粒度でファイルを分割することにしました。
動作確認のやり方についてですが、私はElixirで用意されているIExというインタラクティブシェルを用い、対象のモジュールの関数を呼び出してみるという方法をよく使います。
今回はデータベースからメッセージ送信情報を抽出する処理と、そのメッセージ送信情報から通知文面を整形してChatworkに通知する処理の二つにざっくり分解し、それぞれ別のモジュール(BotWatcher.Repository.MessageSendingInfoRepositoryBotWatcher.Service.MessageSendingInfoChatworkNotificationService)に実装することにしました。これらの実装については後述します。この段階ではtypespecだけ決めて、実装はtypespecを満たす仮の内容としました。
また、コアプログラム自体も動作確認しやすくしたいので、後述するCLIプログラムとコアプログラムも分離することにしました。
コアプログラムとCLIプログラムを一緒にすると、プログラムを変更する度にescriptをビルドする必要がありますが、このように分離しておくとIExに滞在したまま動作確認を行なうことができ、フィードバックサイクルを回すのが捗ります。
その結果、コアプログラムはCLIプログラムから渡された情報を用いて各モジュールの関数を順次呼び出すだけのシンプルな作りになりました。
MessageSendingInfoRepository.summary!関数とMessageSendingInfoChatworkNotificationService.post!関数のtypespecも合わせて載せておきます。

lib/core.ex

defmodule BotWatcher.Core do
  @moduledoc """
  Core application program for `BotWatcher`.
  """

  alias BotWatcher.DomainObject.MessageSendingInfo
  alias BotWatcher.Repository.MessageSendingInfoRepository
  alias BotWatcher.Service.MessageSendingInfoChatworkNotificationService

  @spec main(
          top_n :: integer(),
          threshold_failure_count :: integer(),
          time_window_minutes :: integer(),
          exclusion_chat_linkage_setting_ids :: [String.t()]
        ) :: [MessageSendingInfo.t()]
  def main(
        top_n,
        threshold_failure_count,
        time_window_minutes,
        exclusion_chat_linkage_setting_ids \\ []
      ) do
    summary =
      MessageSendingInfoRepository.summary!(
        top_n,
        threshold_failure_count,
        time_window_minutes,
        exclusion_chat_linkage_setting_ids
      )

    MessageSendingInfoChatworkNotificationService.post!(
      top_n,
      time_window_minutes,
      summary
    )

    summary
  end
end

MessageSendingInfoRepository.summary!関数のspec

@spec summary!(
      top_n :: integer(),
      threshold_failure_count :: integer(),
      time_window_minutes :: integer(),
      exclusion_chat_linkage_setting_ids :: [String.t()]
    ) :: [MessageSendingInfo.t()]

MessageSendingInfoChatworkNotificationService.post!関数のspec

@spec post!(
          top_n :: integer(),
          time_window_minutes :: integer(),
          summary :: [MessageSendingInfo.t()]
        ) :: :ok

データベースからメッセージ送信情報を抽出する処理の実装

コアプログラムの設計を踏まえ、データベースからメッセージ送信に失敗したボットのメッセージ送信情報を抽出する処理を実装していきました。
メッセージ送信情報は統計情報(送信失敗件数、送信失敗件数の変化量)とテナント情報(会社ID、会社名など)から成ります。
これらが一つのデータベースにまとまっていると都合が良かったのですが、今回はそれぞれ別々のデータベースから抽出し統合することでメッセージ送信情報を構築する必要がありました。

前提となるデータベースのスキーマを簡略化したものをMermaidで作成したダイアグラムで表現すると以下のようになります。

統計情報用のデータベーススキーマの概要

テナント情報用のデータベーススキーマの概要

前述の通りデータベース操作はecto_sqlライブラリーを用いて実施します。
ecto_sqlを使用する際はデータベース毎にリポジトリなるものを用意する必要があります。
まずは統計情報用データベース、テナント情報用データベースに対応するリポジトリ(BotWatcher.MessageRepoBotWatcher.MainRepo)をそれぞれ定義しました。

lib/message_repo.ex

defmodule BotWatcher.MessageRepo do
  @moduledoc false

  use Ecto.Repo,
    otp_app: :bot_watcher,
    adapter: Ecto.Adapters.MyXQL
end

lib/main_repo.ex

defmodule BotWatcher.MainRepo do
  @moduledoc false

  use Ecto.Repo,
    otp_app: :bot_watcher,
    adapter: Ecto.Adapters.Postgres
end

各リポジトリについて、データベース接続情報とコネクションプールサイズを設定ファイルで指定しました。
セキュリティ上、秘密情報はソースコードバージョン管理ツールに保存できませんので、escript実行時に環境変数から読み込むように設定しました。
今回作成するツールでデータベースに同時アクセスすることはないのでコネクションプールサイズは1を指定しました。
そもそも今回はコネクションをプールする必要もないのですがお手軽にコネクションを確立できるので今回はこの方法にしました。

config/runtime.exs

import Config

config :logger, level: :info

message_repo_database = System.get_env("BOT_WATCHER_MESSAGE_REPO_DATABASE")
message_repo_username = System.get_env("BOT_WATCHER_MESSAGE_REPO_USERNAME")
message_repo_password = System.get_env("BOT_WATCHER_MESSAGE_REPO_PASSWORD")
message_repo_hostname = System.get_env("BOT_WATCHER_MESSAGE_REPO_HOSTNAME")
message_repo_port = System.get_env("BOT_WATCHER_MESSAGE_REPO_PORT", "3306")

config :bot_watcher, BotWatcher.MessageRepo,
  database: message_repo_database,
  username: message_repo_username,
  password: message_repo_password,
  hostname: message_repo_hostname,
  port: String.to_integer(message_repo_port),
  pool_size: 1

main_repo_database = System.get_env("BOT_WATCHER_MAIN_REPO_DATABASE")
main_repo_username = System.get_env("BOT_WATCHER_MAIN_REPO_USERNAME")
main_repo_password = System.get_env("BOT_WATCHER_MAIN_REPO_PASSWORD")
main_repo_hostname = System.get_env("BOT_WATCHER_MAIN_REPO_HOSTNAME")
main_repo_port = System.get_env("BOT_WATCHER_MAIN_REPO_PORT", "5432")

config :bot_watcher, BotWatcher.MainRepo,
  database: main_repo_database,
  username: main_repo_username,
  password: main_repo_password,
  hostname: main_repo_hostname,
  port: String.to_integer(main_repo_port),
  pool_size: 1

これらのリポジトリがescript実行時に機能するように、BotWatcher.Applicationモジュールに以下のように記述しておきます。

lib/application.ex

defmodule BotWatcher.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      BotWatcher.MessageRepo,
      BotWatcher.MainRepo
    ]

    opts = [strategy: :one_for_one, name: BotWatcher.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

以下、これらのリポジトリを介して必要情報をそれぞれ抽出しメッセージ送信情報のリストとして返す処理の実装について説明します。
BotWatcher.Repository.MessageSendingInfoRepositorysummary!がそれに当たります。
集計はアプリよりもデータベースにさせた方が処理効率がよいことが多いので、今回もそのように実装しました。
SQL文を動的に生成するコードもElixir標準のEnumモジュールを使うことで簡潔に書くことができました。

lib/repository/message_sending_info_repository.ex

defmodule BotWatcher.Repository.MessageSendingInfoRepository do
  @moduledoc """
  Repository for message sending information.
  """

  alias BotWatcher.DomainObject.MessageSendingInfo
  alias BotWatcher.MessageRepo
  alias BotWatcher.MainRepo

  import Ecto.Query, only: [from: 2]

  @spec summary!(
          top_n :: integer(),
          threshold_failure_count :: integer(),
          time_window_minutes :: integer(),
          exclusion_chat_linkage_setting_ids :: [String.t()]
        ) :: [MessageSendingInfo.t()]
  def summary!(
        top_n,
        threshold_failure_count,
        time_window_minutes,
        exclusion_chat_linkage_setting_ids \\ []
      ) do
    ###########################################
    #
    # 1. 統計情報の抽出
    #
    ###########################################

    exclusion_chat_linkage_setting_where_clause =
      case exclusion_chat_linkage_setting_ids do
        [] ->
          ""

        _ ->
          "AND m.chat_linkage_setting_id NOT IN (#{Enum.reduce(exclusion_chat_linkage_setting_ids, "", fn
            _, "" -> "?"
            _, acc -> "?, " <> acc
          end)})"
      end

    message_repo_query =
      """
      SELECT
        m.bot_id,
        m.chat_linkage_setting_id,
        m.chat_code,
        SUM(m.failure_count),
        SUM(m.failure_count) - IFNULL((SELECT SUM(failure_count) FROM messages WHERE chat_linkage_setting_id = m.chat_linkage_setting_id AND send_at BETWEEN(CURRENT_TIMESTAMP - INTERVAL ? MINUTE) AND(CURRENT_TIMESTAMP - INTERVAL ? MINUTE)), 0),
        SUM(m.failure_count) - IFNULL((SELECT SUM(failure_count) FROM messages WHERE chat_linkage_setting_id = m.chat_linkage_setting_id AND send_at BETWEEN(CURRENT_TIMESTAMP - INTERVAL ? MINUTE) AND(CURRENT_TIMESTAMP - INTERVAL ? MINUTE)), 0),
        SUM(m.failure_count) - IFNULL((SELECT SUM(failure_count) FROM messages WHERE chat_linkage_setting_id = m.chat_linkage_setting_id AND send_at BETWEEN(CURRENT_TIMESTAMP - INTERVAL ? MINUTE) AND(CURRENT_TIMESTAMP - INTERVAL ? MINUTE)), 0),
        SUM(m.failure_count) - IFNULL((SELECT SUM(failure_count) FROM messages WHERE chat_linkage_setting_id = m.chat_linkage_setting_id AND send_at BETWEEN(CURRENT_TIMESTAMP - INTERVAL ? MINUTE) AND(CURRENT_TIMESTAMP - INTERVAL ? MINUTE)), 0),
        SUM(m.failure_count) - IFNULL((SELECT SUM(failure_count) FROM messages WHERE chat_linkage_setting_id = m.chat_linkage_setting_id AND send_at BETWEEN(CURRENT_TIMESTAMP - INTERVAL ? MINUTE) AND(CURRENT_TIMESTAMP - INTERVAL ? MINUTE)), 0),
        SUM(m.failure_count) - IFNULL((SELECT SUM(failure_count) FROM messages WHERE chat_linkage_setting_id = m.chat_linkage_setting_id AND send_at BETWEEN(CURRENT_TIMESTAMP - INTERVAL ? MINUTE) AND(CURRENT_TIMESTAMP - INTERVAL ? MINUTE)), 0)
      FROM
        messages m
      WHERE
        send_at > CURRENT_TIMESTAMP() - INTERVAL ? MINUTE
        #{exclusion_chat_linkage_setting_where_clause}
      GROUP BY
        m.bot_id,
        m.chat_linkage_setting_id,
        m.chat_code
      HAVING
        SUM(m.failure_count) > ?
      ORDER BY
        SUM(m.failure_count) DESC
      LIMIT ?;
      """

    message_repo_query_params =
      [
        # 1 日
        60 * 24 + time_window_minutes,
        60 * 24,
        # 1 週
        60 * 24 * 7 + time_window_minutes,
        60 * 24 * 7,
        # 2 週
        60 * 24 * 7 * 2 + time_window_minutes,
        60 * 24 * 7 * 2,
        # 3 週
        60 * 24 * 7 * 3 + time_window_minutes,
        60 * 24 * 7 * 3,
        # 4 週
        60 * 24 * 7 * 4 + time_window_minutes,
        60 * 24 * 7 * 4,
        # 5 週
        60 * 24 * 7 * 5 + time_window_minutes,
        60 * 24 * 7 * 5,
        time_window_minutes
      ] ++
        Enum.map(exclusion_chat_linkage_setting_ids, &Ecto.UUID.dump!(&1)) ++
        [
          threshold_failure_count,
          top_n
        ]

    %{rows: rows} =
      Ecto.Adapters.SQL.query!(
        MessageRepo,
        message_repo_query,
        message_repo_query_params
      )

    # list(list(any))の構造だと扱いにくいのですぐにlist(map)に変換しています。
    stat_records =
      Enum.map(rows, fn [
                          bot_id_binary,
                          chat_linkage_setting_id_binary,
                          chat_code,
                          current_failure_count,
                          failure_diff_compared_to_yesterday,
                          failure_diff_compared_to_last_week,
                          failure_diff_compared_to_2_weeks_ago,
                          failure_diff_compared_to_3_weeks_ago,
                          failure_diff_compared_to_4_weeks_ago,
                          failure_diff_compared_to_5_weeks_ago
                        ] ->
        %{
          bot_id: Ecto.UUID.cast!(bot_id_binary),
          bot_id_binary: bot_id_binary,
          chat_linkage_setting_id: Ecto.UUID.cast!(chat_linkage_setting_id_binary),
          chat_linkage_setting_id_binary: chat_linkage_setting_id_binary,
          chat_code: chat_code,
          current_failure_count: Decimal.to_integer(current_failure_count),
          failure_diff_compared_to_yesterday:
            Decimal.to_integer(failure_diff_compared_to_yesterday),
          failure_diff_compared_to_last_week:
            Decimal.to_integer(failure_diff_compared_to_last_week),
          failure_diff_compared_to_2_weeks_ago:
            Decimal.to_integer(failure_diff_compared_to_2_weeks_ago),
          failure_diff_compared_to_3_weeks_ago:
            Decimal.to_integer(failure_diff_compared_to_3_weeks_ago),
          failure_diff_compared_to_4_weeks_ago:
            Decimal.to_integer(failure_diff_compared_to_4_weeks_ago),
          failure_diff_compared_to_5_weeks_ago:
            Decimal.to_integer(failure_diff_compared_to_5_weeks_ago)
        }
      end)

    ###########################################
    #
    # 2. テナント情報の抽出
    #
    ###########################################

    tenant_records =
      MainRepo.all(
        from(chat_linkage_setting in "chat_linkage_settings",
          join: bot in "bots",
          on: bot.id == chat_linkage_setting.bot_id,
          join: company in "companies",
          on: company.id == bot.company_id,
          where:
            chat_linkage_setting.id in ^Enum.map(
              stat_records,
              & &1.chat_linkage_setting_id_binary
            ),
          select: {
            company.id,
            company.name,
            bot.name,
            chat_linkage_setting.id,
            chat_linkage_setting.name,
            chat_linkage_setting.chat_code
          }
        )
      )
      |> Enum.into(%{}, fn {
                             company_id,
                             company_name,
                             bot_name,
                             chat_linkage_setting_id_binary,
                             chat_linkage_setting_name,
                             chat_code
                           } ->
        {
          Ecto.UUID.cast!(chat_linkage_setting_id_binary),
          %{
            company_id: company_id,
            company_name: company_name,
            bot_name: bot_name,
            chat_linkage_setting_name: chat_linkage_setting_name,
            chat_code: chat_code
          }
        }
      end)

    ###########################################
    #
    # 3. メッセージ送信情報の構築
    #
    ###########################################

    Enum.map(stat_records, fn a_stat_record ->
      a_tenant_record = tenant_records[a_stat_record.chat_linkage_setting_id]

      %MessageSendingInfo{
        company_id: a_tenant_record.company_id,
        company_name: a_tenant_record.company_name,
        bot_name: a_tenant_record.bot_name,
        chat_linkage_setting_name: a_tenant_record.chat_linkage_setting_name,
        chat_code: a_tenant_record.chat_code,
        current_failure_count: a_stat_record.current_failure_count,
        failure_diff_compared_to_yesterday:
          a_stat_record.failure_diff_compared_to_yesterday,
        failure_diff_compared_to_last_week:
          a_stat_record.failure_diff_compared_to_last_week,
        failure_diff_compared_to_2_weeks_ago:
          a_stat_record.failure_diff_compared_to_2_weeks_ago,
        failure_diff_compared_to_3_weeks_ago:
          a_stat_record.failure_diff_compared_to_3_weeks_ago,
        failure_diff_compared_to_4_weeks_ago:
          a_stat_record.failure_diff_compared_to_4_weeks_ago,
        failure_diff_compared_to_5_weeks_ago:
          a_stat_record.failure_diff_compared_to_5_weeks_ago
      }
    end)
  end
end

メッセージ投稿処理の実装

続いてメッセージ投稿処理の実装について説明します。
まずはChatworkのWeb APIを呼び出す処理がうまく機能することを確認したかったので、その処理を切り出したモジュール(BotWatcher.Adapter.ChatworkAdapter)を作成しました。

lib/adapter/chatwork_adapter.ex

defmodule BotWatcher.Adapter.ChatworkAdapter do
  @moduledoc """
  Adaptor for Chatwork API.
  """

  @spec post!(message :: String.t(), room_id :: String.t(), chatwork_api_token :: String.t()) ::
          :ok
  def post!(message, room_id, chatwork_api_token) do
    HTTPoison.post!(
      "https://api.chatwork.com/v2/rooms/#{room_id}/messages",
      {:form, [body: message]},
      [
        {"x-chatworktoken", chatwork_api_token},
        {"accept", "application/json"}
      ]
    )

    :ok
  end
end

次にメッセージ送信に失敗したボットを調べた結果に基づいてChatworkに投稿する本文を作成する処理を実装しました。
BotWatcher.Service.MessageSendingInfoChatworkNotificationServiceモジュールの内部にChatworkViewモジュールを作成し、その中にnotification_message関数として実装しました。
メッセージ送信に失敗したボットが0件の場合と1件以上の場合の条件分岐を関数の引数のパターンマッチで書き分けました。このように関数の引数のパターンマッチで条件分岐を記述できるのはElixirの特徴の一つです。
また、それぞれのボットのメッセージ送信情報をChatwork上で表現したい形式の文字列に変換する処理をshow関数として実装しました。

lib/service/message_sending_info_chatwork_notification_service.ex

defmodule BotWatcher.Service.MessageSendingInfoChatworkNotificationService do
  @moduledoc """
  Service for posting message sending information to chatwork.
  """

  alias BotWatcher.DomainObject.MessageSendingInfo

  # 
  # (省略しています)
  # 

  defmodule ChatworkView do
    @moduledoc """
    A module responsible for transforming domain models to Chatwork UI.
    """

    @spec show(MessageSendingInfo.t()) :: String.t(()
    def show(message_sending_info) do
      maybe_plus = fn value ->
        if value > 0, do: "+", else: ""
      end

      """
      #{message_sending_info.company_name} 様 (#{message_sending_info.company_id}, #{message_sending_info.bot_name}, #{message_sending_info.chat_code})
      現在値:#{message_sending_info.current_failure_count}
      前日同時間帯比:#{maybe_plus.(message_sending_info.failure_diff_compared_to_yesterday)}#{message_sending_info.failure_diff_compared_to_yesterday}
      1 週前同時間帯比:#{maybe_plus.(message_sending_info.failure_diff_compared_to_last_week)}#{message_sending_info.failure_diff_compared_to_last_week}
      2 週前同時間帯比:#{maybe_plus.(message_sending_info.failure_diff_compared_to_2_weeks_ago)}#{message_sending_info.failure_diff_compared_to_2_weeks_ago}
      3 週前同時間帯比:#{maybe_plus.(message_sending_info.failure_diff_compared_to_3_weeks_ago)}#{message_sending_info.failure_diff_compared_to_3_weeks_ago}
      4 週前同時間帯比:#{maybe_plus.(message_sending_info.failure_diff_compared_to_4_weeks_ago)}#{message_sending_info.failure_diff_compared_to_4_weeks_ago}
      5 週前同時間帯比:#{maybe_plus.(message_sending_info.failure_diff_compared_to_5_weeks_ago)}#{message_sending_info.failure_diff_compared_to_5_weeks_ago}
      """
    end

    @spec notification_message(
            top_n :: integer(),
            time_window_minute :: integer(),
            summary :: [MessageSendingInfo.t()]
          ) :: String.t()

    def notification_message(_top_n, time_window_minutes, []) do
      "(F)過去 #{time_window_minutes} 分以内にメッセージ送信に失敗したボットはありません(F)"
    end

    def notification_message(top_n, time_window_minutes, summary) do
      title = "過去 #{time_window_minutes} 分間のメッセージ送信失敗件数 TOP #{top_n}"

      body =
        summary
        |> Enum.with_index(1)
        |> Enum.map_join("\n", fn {message_sending_info, i} ->
          "#{i} 位:#{show(message_sending_info)}"
        end)

      "[info][title]#{title}[/title]#{body}[/info]"
    end
  end

  # 
  # (省略しています)
  # 

end

以上で実装した処理を利用してChatworkにメッセージを投稿する処理をBotWatcher.Service.MessageSendingInfoChatworkNotificationServiceモジュールにpost!関数として実装しました。
メッセージを投稿するグループチャットのIDとメッセージ投稿に使用するChatworkアカウントのAPIトークンは秘密情報のため、escript実行時に環境変数から読み込むように設定しました。

config/runtime.exs

import Config

#
# (省略しています)
#

chatwork_notification_room_id = System.get_env("BOT_WATCHER_CHATWORK_NOTIFICATION_ROOM_ID")
chatwork_api_token = System.get_env("BOT_WATCHER_CHATWORK_API_TOKEN")

config :bot_watcher,
  chatwork_notification_room_id: chatwork_notification_room_id,
  chatwork_api_token: chatwork_api_token

lib/service/message_sending_info_chatwork_notification_service.ex

defmodule BotWatcher.Service.MessageSendingInfoChatworkNotificationService do
  @moduledoc """
  Service for posting message sending information to chatwork.
  """

  alias BotWatcher.DomainObject.MessageSendingInfo
  alias BotWatcher.Adapter.ChatworkAdapter

  def chatwork_notification_room_id do
    Application.get_env(:bot_watcher, :chatwork_notification_room_id)
  end

  def chatwork_api_token do
    Application.get_env(:bot_watcher, :chatwork_api_token)
  end

  defmodule ChatworkView do
    #
    # (省略しています)
    #
  end

  @spec post!(
              top_n :: integer(),
              time_window_minute :: integer(),
              summary :: [MessageSendingInfo.t()]
            ) :: :ok
  def post!(top_n, time_window_minutes, summary) do
    ChatworkAdapter.post!(
      ChatworkView.notification_message(top_n, time_window_minutes, summary),
      chatwork_notification_room_id(),
      chatwork_api_token()
    )
  end
end

CLIプログラムの実装

最後に、escriptの呼び出し元から入力された引数を用いてコアプログラムを呼び出し、その結果をJSON文字列で標準出力するCLIプログラムを実装しました。
引数のデータ型はすべて文字列のため、コアプログラムが期待するデータ型にここで変換しておきます。
summary変数のデータ型は[BotWatcher.DomainObject.MessageSendingInfo.t()]ですが、BotWatcher.DomainObject.MessageSendingInfo構造体定義に@derive [Jason.Encoder]を記述したおかげでJason.encode!関数に渡すだけでJSON文字列表現に変換することができました。
このように、CLIプログラムの実装はコマンドラインの世界とElixirの世界の架け橋としての役割のみを担うシンプルな内容になりました。

defmodule BotWatcher.CLI do
  alias BotWatcher.Core

  @moduledoc """
  CLI for `BotWatcher`.
  """

  def main([
        top_n_string,
        threshold_failure_count_string,
        time_window_minutes_string
        | exclusion_chat_linkage_setting_ids
      ]) do
    top_n = String.to_integer(top_n_string)
    threshold_failure_count = String.to_integer(threshold_failure_count_string)
    time_window_minutes = String.to_integer(time_window_minutes_string)

    summary =
      Core.main(
        top_n,
        threshold_failure_count,
        time_window_minutes,
        exclusion_chat_linkage_setting_ids
      )

    IO.puts(Jason.encode!(summary))
  end
end

デプロイ

デプロイ先ホストにASDFでElixirとErlangがインストールされていることを前提とします。
必要な環境変数が多いので、以下のようなシェルスクリプトで必要な環境変数を一度に定義できるようにしておきました。

/home/someuser/bot_watcher_env.sh
※以下はサンプルです。

export BOT_WATCHER_MESSAGE_REPO_DATABASE='message_repo_database'
export BOT_WATCHER_MESSAGE_REPO_USERNAME='message_repo_username'
export BOT_WATCHER_MESSAGE_REPO_PASSWORD='*********************'
export BOT_WATCHER_MESSAGE_REPO_HOSTNAME='message_repo_hostname'
export BOT_WATCHER_MESSAGE_REPO_PORT='3306'
export BOT_WATCHER_MAIN_REPO_DATABASE='main_repo_databasae'
export BOT_WATCHER_MAIN_REPO_USERNAME='main_repo_username'
export BOT_WATCHER_MAIN_REPO_PASSWORD='******************'
export BOT_WATCHER_MAIN_REPO_HOSTNAME='main_repo_hostname'
export BOT_WATCHER_MAIN_REPO_PORT='5432'
export BOT_WATCHER_CHATWORK_NOTIFICATION_ROOM_ID='*********'
export BOT_WATCHER_CHATWORK_API_TOKEN='********************************'

デプロイ先ホストにてbot_watcherのGitリポジトリをクローンし、escriptをビルドしました。

cd /home/someuser/
git clone {git remote repository url}
cd bot_watcher/
mix escript.build

そしてescriptを毎時25分に定期実行するように、crontabに以下の通りジョブを登録しました。
cronジョブは非対話シェルで実行されるため、/home/someuser/.bashrc. /home/someuser/.asdf/asdf.sh"と記載していてもcronジョブでASDFが有効になりません。
ですのでcronジョブでASDFを有効にするためにcronジョブの中で/home/someuser/.asdf/asdf.shを読み込むようにしました。
また、ASDFをbashで利用する前提になっていたため、コマンド全体を/bin/bash -c "..."でラップしました。

# m h  dom mon dow   command
  25  *  *   *   *     /bin/bash -c ". /home/someuser/.asdf/asdf.sh && . /home/someuser/bot_watcher_env.sh && cd /home/someuser/bot_watcher/ && ./bot_watcher 5 5 80"

おわりに

今回は運用監視強化を目的としてElixirでツールを開発しました。
その過程で考えたことや工夫したこと、役に立ったElixirの機能について紹介させていただきました。
運用改善や開発の進め方、Elixirの使い方の一例として参考になれば幸いです。