2021/10/23

[Rust]アプリケーションエラーをErrbitにレポートする

rusterrbit

Rustのアプリケーションエラーの監視に関するお話です。

Rustでエラーが起きた時に、エラー情報をErrbitにレポートするライブラリを開発しました。
また、actix-webで使えるmiddlewareもライブラリ化してみました。

以下作ったものです。

注意点

  • actix-webはversion4.0.0-beta.10に対応させています。(まだbetaバージョンなので注意)
    • actix-webの3系はtokio runtimeが古く、tokio 1系に対応させたいため、4系のものを使っています。

Errbitとは

Errbit is a tool for collecting and managing errors from other applications. It is Airbrake API compliant, so if you are already using Airbrake, you can just point the airbrake gem to your Errbit server. Documentation is available for all released versions of Errbit and master. It is built directly from whatever documentation was available in the ./docs folder at the time of release.

Errbitはアプリケーションエラーを検知・収集するためのOpensourceのソフトウェアです。主にRuby on Rails向けに開発されていたようですが、Rubyではなく、別の言語でも使えます。(httpでpostするだけなので)
レポート用のAPIはAirbrakeと互換性があるため、aribrakeのclientライブラリを流用することができます。

エラーレポートのサービスに関しては、その他にはSentryが有名で、こちらもいくつかのプロジェクトで(有料版を)利用しています。SentryもOSSなのですが、構築コストと運用コストが高い(と判断した)ため自前でホスティングして使ったりはしていません。

私が運用・管理しているアプリケーションサーバーのほとんどは、自前で立てたErrbitを使ってエラーの収集・検知を行なっています。(規模が大きいものはSentryを使っていたりします。)

slackのincoming webhookなどと連携できたりするのも便利です。

RustでのErrorの取り扱い

RustでのErrorの取り扱いはかなり頭を悩ませます。

最近バックエンドのメイン言語をPythonからRustに変更することを決意したのですが・・PythonでできるあんなことやこんなことをRustでやろうとするとコードがものすごい煩雑になってしまいます。

Errorハンドリングもその一つでした。。。

RustのError定義のデファクトスタンダードの一つであるanyhowのgithub Star数を見てもわかる通り、Errorをラップするだけのライブラリがこれほどの人気があること自体が異常自体です。。。

Errbitにエラーを通知するときのデータとして、Backtrace情報を一緒にPOSTするのですが、そのBacktrace情報を加工するのに、言語レベルでサポートされているstd::error::Errorではうまくいかないので、anyhow::Errorを利用します。

また、actix-webではanyhow::Errorがデフォルトでサポートされないので、うまく組み込む必要があり、そちらの対応も必要になってしまいました。。。

将来的には、言語レベルでサポートしているstd::error::Errorの使い勝手が良くなり、anyhowなどは不要になってくるとは思いますが・・それまでは我慢して付き合って行く必要がありそうです。

errbit-rs

github.com/kumanote/errbit-rsの説明です。

APIのインターフェース定義を見ながら、postするデータの構造体を作ります。

できたものがNotice structです。ErrbitにPOSTするときは、この構造体のインスタンスを作ってjsonに変換してPOSTします。

このPOSTするデータの中に、backtrace情報を付加することができるのですが・・・anyhow::ErrorからBacktraceを文字列で出力することができるので、それをうまくParseしてbacktrace情報を作ってあげます。(以下のような感じ)

#[derive(Debug, Serialize)]
pub struct ErrorInfo {
    #[serde(rename = "type")]
    pub type_: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub backtrace: Option<Vec<BacktraceInfo>>,
}

#[derive(Clone, Debug, Serialize)]
pub struct BacktraceInfo {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub file: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub function: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub column: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub code: Option<HashMap<String, String>>,
}

impl From<&anyhow::Error> for ErrorInfo {
    fn from(error: &anyhow::Error) -> Self {
        let type_ = format!("{:?}", error.root_cause())
            .split_whitespace()
            .next()
            .unwrap()
            .to_owned();
        let message = format!("{}", error);
        let backtrace_string = format!("{}", error.backtrace());
        let backtraces: Vec<&str> = backtrace_string
            .split("\n")
            .into_iter()
            .filter(|s| !s.is_empty())
            .map(|s| s.trim())
            .collect();
        let mut backtrace_infos = vec![];
        let mut item = BacktraceInfo::default();
        let mut backtrace_iter = backtraces.into_iter();
        loop {
            if let Some(t) = backtrace_iter.next() {
                if t.starts_with("at ") {
                    let position_part = &t[3..];
                    let position_info: Vec<&str> = position_part.split(":").into_iter().collect();
                    if position_info.len() > 0 {
                        item.file = Some(position_info[0].to_owned())
                    }
                    if position_info.len() > 1 {
                        if let Ok(l) = position_info[1].parse::<usize>() {
                            item.line = Some(l)
                        }
                    }
                    if position_info.len() > 2 {
                        if let Ok(c) = position_info[2].parse::<usize>() {
                            item.column = Some(c)
                        }
                    }
                    backtrace_infos.push(item.clone());
                    item = BacktraceInfo::default();
                } else {
                    item = BacktraceInfo::default();
                    item.function = Some(t.to_owned())
                }
            } else {
                if !item.is_empty() {
                    backtrace_infos.push(item.clone())
                }
                break;
            }
        }
        Self {
            type_,
            message,
            backtrace: Some(backtrace_infos),
        }
    }
}

最終的には、以下のような感じで使えるようにしました。anyhow::ErrorであればBacktrace情報つきでErrbitにエラーをPOSTすることができるようになりました。

use errbit::{Config, Notice, Notifier, Result};
use anyhow::{Context, Result};

#[tokio::main]
async fn main() -> Result<()>  {
    let mut config = Config::default();
    config.host = "https://errbit.yourdomain.com".to_owned();
    config.project_id = "1".to_owned();
    config.project_key = "ffffffffffffffffffffffffffffffff".to_owned();
    config.environment = Some("staging".to_owned());
    let notifier = Notifier::new(config)?;
    let double_number =
        |number_str: &str| -> Result<i32> {
            number_str
                .parse::<i32>()
                .map(|n| 2 * n)
                .with_context(|| format!("Failed to parse number_str of {}", number_str))
        };
    let err = double_number("NOT A NUMBER").err().unwrap();
    let result = notifier.notify_anyhow_error(err).await?;
    println!("{}", result.id);
    Ok(())
}

actix-errbit

上記のerrbit-rsをうまく使って、actix-webのミドルウェアを作ってみました。
actix-webのhandlerで500番台のエラーが起きたら、Errbitに通知するような感じです。(まだちゃんとテストできてないので、流用する際は注意してください。)

このmiddlewareの開発にはactix/example - basics/middlewareがかなり参考になりました。

以下のようにして使えるようにしました。

Errorの定義(error.rs)

use actix_web::http::{header, StatusCode};
use actix_web::{HttpResponse, ResponseError};
use serde_json::json;
use serde_json::Value as JsonValue;
use thiserror::Error as ThisError;

pub type Error = actix_errbit::Error;
pub type Result<T> = std::result::Result<T, actix_errbit::Error>;

impl From<CustomError> for actix_errbit::Error {
    fn from(custom_error: CustomError) -> Self {
        Error::from(MyError::from(custom_error))
    }
}

pub struct MyError(anyhow::Error);

impl std::fmt::Debug for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self.0)
    }
}

impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl ResponseError for MyError {
    fn error_response(&self) -> HttpResponse {
        match self.0.downcast_ref::<CustomError>() {
            Some(err) => err.error_response(),
            None => HttpResponse::InternalServerError().body("oops...unknown error occurred..."),
        }
    }
}

impl actix_errbit::ErrbitError for MyError {
    fn as_anyhow(&self) -> Option<&anyhow::Error> {
        Some(&self.0)
    }
}

impl From<anyhow::Error> for MyError {
    fn from(err: anyhow::Error) -> Self {
        Self(err)
    }
}

impl From<CustomError> for MyError {
    fn from(custom_error: CustomError) -> Self {
        Self(custom_error.into())
    }
}

#[derive(ThisError, Debug)]
pub enum CustomError {
    #[error("Unauthorized: {detail}")]
    Unauthorized { detail: JsonValue },
    #[error("ServiceUnavailable: {detail}")]
    ServiceUnavailable { detail: JsonValue },
    #[error("InternalServerError: {detail}")]
    InternalServerError { detail: JsonValue },
}

impl CustomError {
    fn status_code(&self) -> StatusCode {
        match self {
            CustomError::Unauthorized { detail: _ } => StatusCode::UNAUTHORIZED,
            CustomError::ServiceUnavailable { detail: _ } => StatusCode::SERVICE_UNAVAILABLE,
            CustomError::InternalServerError { detail: _ } => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
    fn body(&self) -> String {
        let json = match self {
            CustomError::Unauthorized { detail } => {
                json!({
                    "error": {
                        "detail": detail,
                    }
                })
            }
            CustomError::ServiceUnavailable { detail } => {
                json!({
                    "error": {
                        "detail": detail,
                    }
                })
            }
            CustomError::InternalServerError { detail } => {
                json!({
                    "error": {
                        "detail": detail,
                    }
                })
            }
        };
        format!("{}", json)
    }
}

impl ResponseError for CustomError {
    fn error_response(&self) -> HttpResponse {
        let mut res = HttpResponse::new(self.status_code());
        res.headers_mut().insert(
            header::CONTENT_TYPE,
            header::HeaderValue::from_static("application/json; charset=utf-8"),
        );
        // HttpResponse::Unauthorized().body(format!("detail: {}", detail))
        res.set_body(self.body().into())
    }
}

middlewareの登録

mod error;
mod endpoint;

use error::{CustomError, Error, Result};
use actix_errbit::{Config as ErrbitConfig, Errbit};
use actix_web::{web, App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let mut errbit_config = ErrbitConfig::default();
    errbit_config.host = "https://errbit.yourdomain.com".to_owned();
    errbit_config.project_id = "1".to_owned();
    errbit_config.project_key = "ffffffffffffffffffffffffffffffff".to_owned();
    errbit_config.environment = Some("staging".to_owned());
    HttpServer::new(move || {
        let errbit = Errbit::new(errbit_config.clone())
            .expect("the errbit endpoint to report error to must be configured...");
        App::new()
            .wrap(errbit)
            .configure(endpoint::routes)
    })
        .bind("127.0.0.1:8080")?
        .run()
        .await

endpoint/mod.rs

use actix_web::web;

mod index;

pub fn routes(app: &mut web::ServiceConfig) {
    app.service(web::resource("/").route(web::get().to(index::handler)));
}

routes内でのErrorハンドリング(endpoint/index.rs)

以下では100%エラーになるようにしていますが・・
処理に応じて先程定義したCustomErroractix_errbit::Errorに変換してreturnすると、middleware側でerrbitに自動的にエラーが送信されるようになります。

use crate::{CustomError, Result};
use actix_web::{web, HttpResponse};
use serde_json::json;

pub async fn handler(_req: web::HttpRequest) -> Result<HttpResponse> {
    Err(CustomError::ServiceUnavailable {
        detail: json!("Oops... something wrong..."),
    }
    .into());
}

今回作ったライブラリは、内部向けで作ったものなので、crates.ioに公開は予定がないですが・・

kumanoteのプロジェクトで運用してみて、ある程度安定してきたら公開しても良いかなと思っています。

以上です。Rustでのエラーハンドリングやactix-webのmiddlewareの開発方法などで参考になれば幸いです。