2021/10/23
[Rust]アプリケーションエラーをErrbitにレポートする
Rustのアプリケーションエラーの監視に関するお話です。
Rustでエラーが起きた時に、エラー情報をErrbitにレポートするライブラリを開発しました。
また、actix-webで使えるmiddlewareもライブラリ化してみました。
以下作ったものです。
注意点
- actix-webはversion
4.0.0-beta.10
に対応させています。(まだbetaバージョンなので注意)- actix-webの
3
系はtokio runtimeが古く、tokio1
系に対応させたいため、4
系のものを使っています。
- actix-webの
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%エラーになるようにしていますが・・
処理に応じて先程定義したCustomError
をactix_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の開発方法などで参考になれば幸いです。
関連する記事
Concordiumノードをローカルで動かしてみた
Concordiumの調査のために、ローカルでソースコードをビルドしてノードを動かしてみました
[Rust]axumとdragonflyを使ったWebsocket Chatのサンプル実装
redis互換のdragonflyをPUBSUBとして利用して、Websocket Chatアプリのサンプル実装を行いました。
[Rust]TiDBを使ったサンプルアプリケーションの実装
RustからTiDBを使ったアプリケーションの実装を行いました。
[Rust]Google Cloud Storageを利用する
GCSやNFSのファイルを扱えるpackageをRustで実装しました。