New Relicで実現するRust・Axumアプリのトレースとログ管理

New Relicを使ったRust・Axumアプリのログとトレース

## 概要

本記事では、Rust・Axumアプリケーションの監視にNew Relicを導入し、ログとトレースを取得する方法を紹介します。
その過程で、ログとトレースを紐づけるために、crateに簡単なPatchを当てたことも紹介します。

今回使うコードは、以下のリポジトリにあります。

https://github.com/Romira915/rust-axum-newrelic-example

## 前提条件

- Rustの開発環境が整っていること
- New Relicのアカウントがあること

## New Relicについて

New Relicでアプリケーションの監視を行う場合、言語が対応していればAPM（Application Performance Monitoring）を利用することが一般的です。

しかし残念ながら、RustのAPMは提供されていません（ぜひ提供してほしいですね）。

ですが、New RelicはOpenTelemetryをサポートしており、OpenTelemetryを利用することで、Rustアプリケーションのトレースを取得することができます。

## RustにOpenTelemetryを導入する

今回使用するcrateをCargo.tomlに追加します。

```Cargo.toml
[dependencies]
# 1. axum関連
axum = { version = "0.7.9" }
tokio = { version = "1.42.0", features = ["rt-multi-thread"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["trace"] }

# 2. トレース関連
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = [
    "env-filter",
    "fmt",
    "std",
    "time",
] }

# 3. opentelemetry関連
opentelemetry = { version = "0.27.1", features = ["metrics", "logs"] }
opentelemetry-http = "0.27.0"
opentelemetry-otlp = { version = "0.27.0", features = [
    "tonic",
    "metrics",
    "logs",
    "tracing",
    "http-proto",
    "reqwest-client",
    "reqwest-rustls",
    "tls",
    "tls-roots",
    "opentelemetry-http",
] }
opentelemetry-semantic-conventions = { version = "0.27.0", features = [
    "semconv_experimental",
] }
opentelemetry-stdout = { version = "0.27.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.27.1", features = [
    "rt-tokio",
    "logs",
    "metrics",
] }
tracing-opentelemetry = { version = "0.28.0", features = [] }
opentelemetry-appender-tracing = "0.27.0"

# 4. その他
time = { version = "0.3.37", features = ["macros", "formatting"] }
anyhow = "1.0.94"
```

数が多いですが、追加しているcrateは大きく分けて３つです。

1. axum関連
2. トレース関連
3. opentelemetry関連

次に、New Relicにログ・トレースを送信するための`Provider`を作成します。

今回はメトリクスには触れません（上手く取得できてないので…）。

```rust
fn resource(new_relic_service_name: &str, host_name: &str) -> Resource {
    Resource::new(vec![
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::SERVICE_NAME,
            new_relic_service_name.to_string(),
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::HOST_NAME,
            host_name.to_string(),
        ),
    ])
}

fn init_logger_provider(
    new_relic_otlp_endpoint: &str,
    new_relic_license_key: &str,
    new_relic_service_name: &str,
    host_name: &str,
) -> Result<LoggerProvider, opentelemetry_sdk::logs::LogError> {
    let exporter = LogExporter::builder()
        .with_http()
        .with_endpoint(format!("{}/v1/logs", new_relic_otlp_endpoint))
        .with_headers(HashMap::from([(
            "api-key".into(),
            new_relic_license_key.into(),
        )]))
        .with_protocol(Protocol::HttpJson)
        .build()?;

    Ok(LoggerProvider::builder()
        .with_resource(resource(new_relic_service_name, host_name))
        .with_batch_exporter(exporter, runtime::Tokio)
        .build())
}

fn init_tracer_provider(
    new_relic_otlp_endpoint: &str,
    new_relic_license_key: &str,
    new_relic_service_name: &str,
    host_name: &str,
) -> Result<SdkTracerProvider, TraceError> {
    let exporter = SpanExporter::builder()
        .with_http()
        .with_endpoint(format!("{}/v1/traces", new_relic_otlp_endpoint))
        .with_headers(HashMap::from([(
            "api-key".into(),
            new_relic_license_key.into(),
        )]))
        .with_protocol(Protocol::HttpJson)
        .build()?;

    Ok(SdkTracerProvider::builder()
        .with_resource(resource(new_relic_service_name, host_name))
        .with_batch_exporter(exporter, runtime::Tokio)
        .build())
}

fn init_metrics(
    new_relic_otlp_endpoint: &str,
    new_relic_license_key: &str,
    new_relic_service_name: &str,
    host_name: &str,
) -> Result<opentelemetry_sdk::metrics::SdkMeterProvider, MetricError> {
    let exporter = MetricExporter::builder()
        .with_http()
        .with_endpoint(format!("{}/v1/metrics", new_relic_otlp_endpoint))
        .with_headers(HashMap::from([(
            "api-key".into(),
            new_relic_license_key.into(),
        )]))
        .with_protocol(Protocol::HttpJson)
        .build()?;

    let reader =
        opentelemetry_sdk::metrics::PeriodicReader::builder(exporter, runtime::Tokio).build();

    Ok(opentelemetry_sdk::metrics::SdkMeterProvider::builder()
        .with_reader(reader)
        .with_resource(resource(new_relic_service_name, host_name))
        .build())
}
```

今回はhttpプロトコルを使用していますが、gRPCも利用可能です。

また、ログ・トレース・メトリクスの各エンドポイントは`$BASE_URL/v1/logs`、`$BASE_URL/v1/traces`、`$BASE_URL/v1/metrics`になるようです。

参考

https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/

New Relicに送信するときにアプリケーション情報も同時に送信することで、New Relic上でアプリケーションを識別することができます。

今回は`SERVICE_NAME`と`HOST_NAME`を送信していますが、他にも様々な情報を送信することができます。

Rustでは、`opentelemetry_semantic_conventions`でリソースのセマンティック規則が提供されています。

https://docs.rs/opentelemetry-semantic-conventions/latest/opentelemetry_semantic_conventions/resource/index.html

```rust
Resource::new(vec![
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::SERVICE_NAME,
            new_relic_service_name.to_string(),
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::HOST_NAME,
            host_name.to_string(),
        ),
    ])
```

次に、これらの`Provider`を組み立てて、`tracing`と`opentelemetry`を連携させます。

`tracing`と`opentelemetry`を連携させることで、`tracing`で記録したログやスパンを`opentelemetry`のトレースに紐づけることができます。

```rust
pub fn init_opentelemetry(
    new_relic_otlp_endpoint: &str,
    new_relic_license_key: &str,
    new_relic_service_name: &str,
    host_name: &str,
) -> anyhow::Result<()> {
    let tracer_provider = init_tracer_provider(
        &new_relic_otlp_endpoint,
        &new_relic_license_key,
        &new_relic_service_name,
        &host_name,
    )?;
    opentelemetry::global::set_tracer_provider(tracer_provider.clone());
    let tracer = tracer_provider.tracer(new_relic_service_name.to_string());

    let fmt_layer = tracing_subscriber::fmt::layer()
        .with_ansi(true)
        .with_file(true)
        .with_line_number(true)
        .with_target(true);
    let env_filter =
        tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
    let otel_trace_layer = tracing_opentelemetry::OpenTelemetryLayer::new(tracer)
        .with_error_records_to_exceptions(true)
        .with_error_fields_to_exceptions(true)
        .with_error_events_to_status(true)
        .with_error_events_to_exceptions(true)
        .with_location(true);
    let otel_metrics_layer = tracing_opentelemetry::MetricsLayer::new(init_metrics(
        new_relic_otlp_endpoint,
        new_relic_license_key,
        new_relic_service_name,
        host_name,
    )?);
    let logger_provider = init_logger_provider(
        new_relic_otlp_endpoint,
        new_relic_license_key,
        new_relic_service_name,
        host_name,
    )?;
    let otel_logs_layer =
        opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(&logger_provider);

    tracing_subscriber::registry()
        .with(fmt_layer)
        .with(env_filter)
        .with(otel_trace_layer)
        .with(otel_metrics_layer)
        .with(otel_logs_layer)
        .init();

    Ok(())
}
```

tracingのフォーマットとログレベルを設定した後、opentelemetryのtrace, metrics, logsの各レイヤーを`tracing_subscriber`に追加しています。

## New Relicにログを送信してみる

ここまでで、ログとトレースを取得するための準備が整いました。

次に、ログを送信してみます。

```rust
pub fn init_opentelemetry(
    new_relic_otlp_endpoint: &str,
    new_relic_license_key: &str,
    new_relic_service_name: &str,
    host_name: &str,
) -> anyhow::Result<()> {
    # ...省略

    tracing_subscriber::registry()
        .with(fmt_layer)
        .with(env_filter)
        .with(otel_trace_layer)
        .with(otel_metrics_layer)
        .with(otel_logs_layer)
        .init();

    tracing::info!("OpenTelemetry initialized");

    logger_provider.force_flush();

    Ok(())
}

#[tokio::main]
async fn main() {
    let newrelic_license_key =
        std::env::var("NEWRELIC_LICENSE_KEY").expect("NEWRELIC_LICENSE_KEY env var is required");

    init_opentelemetry(
        "https://otlp.nr-data.net",
        &newrelic_license_key,
        "axum-newrelic-example",
        "localhost",
    )
    .expect("Failed to initialize OpenTelemetry");
}
```

今回は確認のために、init_opentelemetry関数を呼び出すと同時に`tracing::info!`でログを出力しています。

またログは非同期で送信されるため、`logger_provider.force_flush()`で強制的に送信しています。

new relicのOTLPエンドポイントは`https://otlp.nr-data.net`です。

参考

https://docs.newrelic.com/jp/docs/opentelemetry/best-practices/opentelemetry-otlp/

`NEWRELIC_LICENSE_KEY`を環境変数に設定して、アプリケーションを起動します。

```shell
NEWRELIC_LICENSE_KEY=YOUR_LICENSE_KEY cargo run
```

`OpenTelemetry initialized`というログを出力して終了するだけのアプリケーションです。
実行後にNew RelicのAPM & Servicesを確認すると`axum-newrelic-example`というサービスが追加されているはずです。

![step1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413479/ffa2bf0e-7d13-2c50-e9d2-e6422ef28707.png)

Logsを確認すると、先ほど出力したログが送信されていることが確認できます。

![step2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413479/6450bd0a-e93c-93ff-57bb-501ff96ad00b.png)

## AxumアプリにNew Relicを導入する

ログを送信できることが確認できたので、次にAxumアプリにNew Relicを導入します。

`Hello, World!`を返すだけのAxumアプリを作成します。

```rust
#[tokio::main]
async fn main() {
    let newrelic_license_key =
        std::env::var("NEWRELIC_LICENSE_KEY").expect("NEWRELIC_LICENSE_KEY env var is required");

    init_opentelemetry(
        "https://otlp.nr-data.net",
        &newrelic_license_key,
        "axum-newrelic-example",
        "localhost",
    )
    .expect("Failed to initialize OpenTelemetry");

    // build our application with a route
    let app = Router::new()
        .route("/", get(handler))
        .layer(MakeSpanForHttp.into_tracing_service());

    // run it
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    println!("listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

pub async fn handler() -> Html<&'static str> {
    Html("<h1>Hello, World!</h1>")
}
```

`localhost:3000`にアクセスすると`Hello, World!`が表示されるはずです。

## トレースを送信してみる

次に、トレースを送信してみます。

handler関数を以下のように修正し、handlerから呼び出されるsub1関数とsub2関数を追加します。

```rust
#[tracing::instrument]
pub async fn handler() -> Html<&'static str> {
    tracing::info!("request received");
    Html("<h1>Hello, World!</h1>")
}

#[tracing::instrument]
pub fn sub1(call_fn: &str) {
    tracing::info!(name: "sub1", call_fn = call_fn, "info log");
    sub2("sub1");
}

#[instrument]
pub fn sub2(call_fn: &str) {
    tracing::error!(name: "sub2" ,"error log");
}
```

`instrument`アトリビュートを使用することで、関数の開始時に自動的にスパンを生成してくれるようになります。

axum serverを起動して、`localhost:3000`にアクセスします。

```shell
2024-12-14T13:55:43.787141Z  INFO handler: rust_axum_newrelic_example::handler: src/handler.rs:6: request received
2024-12-14T13:55:43.787278Z  INFO handler:sub1{call_fn="handler"}: rust_axum_newrelic_example::handler: src/handler.rs:15: info log call_fn="handler"
2024-12-14T13:55:43.787374Z ERROR handler:sub1{call_fn="handler"}:sub2{call_fn="sub1"}: rust_axum_newrelic_example::handler: src/handler.rs:21: error log
```

最後のログを見ると、まず`handler`関数が呼び出され、その中で`sub1`関数が引数`call_fn="handler"`で呼び出され、さらに`sub2`関数が引数`call_fn="sub1"`で呼び出されていることがわかります。

New RelicのDistributed tracingを確認すると、トレースが送信されていることが確認できます。

![step3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413479/848b85c7-7ed2-6b70-2fad-54663c9be745.png)

## 各リクエストのトレースを、ハンドラー単位でトランザクションとしてまとめてパフォーマンスモニタリングを可能にする

ここまでで、Distributed tracingでトレースを取得することができました。

しかし、今の状態では１リクエストごとのトレースを見ることができるだけで、全体のパフォーマンスやボトルネックを把握するのは難しいです。

New Relicには、トランザクションという概念があり、複数のリクエストをまとめてパフォーマンスをモニタリングすることができます。

各ハンドラー単位（エンドポイントごとに）でトランザクションを作成し、トランザクションごとにパフォーマンスをモニタリングできるようにします。

axumがリクエストを受けたとき、ハンドラーが呼び出される前にスパンを作成するようにします。

```rust
#[derive(Clone)]
pub(crate) struct MakeSpanForHttp;

impl<B> MakeSpan<B> for MakeSpanForHttp {
    fn make_span(&mut self, request: &Request<B>) -> Span {
        let matched_path = request
            .extensions()
            .get::<MatchedPath>()
            .map_or(request.uri().to_string(), |m| m.as_str().to_string());
        tracing::info_span!(
            "request",
            http.method = %request.method(),
            http.uri = %request.uri(),
            http.version = ?request.version(),
            otel.name = format!("{} {}", request.method(), matched_path),
            otel.kind = "server",
        )
    }
}

impl MakeSpanForHttp {
    pub(crate) fn into_tracing_service(
        self,
    ) -> ServiceBuilder<Stack<TraceLayer<SharedClassifier<ServerErrorsAsFailures>, Self>, Identity>>
    {
        ServiceBuilder::new().layer(TraceLayer::new_for_http().make_span_with(self))
    }
}
```

注目すべきは、`otel.kind = "server"`という部分です。
これにより、New RelicのトランザクションにOpenTelemetryのスパンがマッピングされます。

その他、トランザクション名を`request.method() + matched_path`、リクエスト情報（メソッド、URI、バージョン）をスパンの属性として追加しています。

Axumアプリを起動して数回アクセスし、New RelicのTransactionsを確認すると、`GET /`という名前でトランザクションとしてまとめられていることが確認できます。

![step4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413479/15bf606a-a9c7-47af-4f64-bf492b2022fc.png)

トランザクションから各トレースも確認できます。

![step5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413479/49f01394-e37d-d393-3700-c4659e8beb53.png)

これで、Axumアプリのパフォーマンスをトランザクション単位でモニタリングすることができるようになりました。

## トレースとログを紐づける

さて、ここまででおおよそいい感じにアプリケーションの監視ができるようになりましたが、実はまだ問題があります。

それは、トレースとログが紐づいていないことです。

`GET /`のトレースを確認すると、ログが出力されているはずなのに表示されていません。

![step6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413479/4ee9a679-8cd9-0c61-9fe4-8c523e70a5b0.png)

これは、ログのアトリビュートに`trace.id`や`span.id`が含まれていないためです。

解決策の一つとして、ログを出力する際に毎回`trace.id`や`span.id`をログに含める方法があります。

```rust
tracing::info!(
    trace.id = trace_id,
    span_id = span_id,
    "request received",
);
```

しかし、これを毎回書くのは面倒ですし、漏れがあるとログとトレースを紐づけることができません（いい感じに自動的に属性を付与してくれるカスタムレイヤーなどを実装する方法があれば教えていただきたいです）。

そこで今回は、tracingとOpenTelemetryを連携させている`opentelemetry-appender-tracing`にパッチを当てて、otelログ送信時に`trace.id`や`span.id`を自動的に付与するようにします。

以下がパッチを当てたPRです。
とにかく`trace.id`や`span.id`をアトリビュートに追加したかっただけなので、割と愚直な実装です。

https://github.com/Romira915/opentelemetry-rust/pull/2/files#diff-e9033bd5189bc3b88a31d73e8dfd111a281b76885313fc2c8421b4f9332b452bR177

パッチを当てたcrateをcrates.ioに公開しています。

```shell
cargo add opentelemetry-appender-tracing2
```

`opentelemetry-appender-tracing`を`opentelemetry-appender-tracing2`に置き換えることで、ログとトレースが紐づくようになるはずです。

![step7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/413479/2be0902d-ee0a-430c-1787-bcf8478c0217.png)

## tracingとOpenTelemetryのセットアップをサクッとやってくれるcrate

今回は、tracingとOpenTelemetryを連携させるために、いくつかのcrateを使用しました。

しかし、アプリケーションを作成する際に、毎回これらのcrateをセットアップするのは面倒です（なにより忘れるので毎回exampleを見に行っている）

そこで、tracingとOpenTelemetryのセットアップをサクッとやってくれるcrateがあると便利だと思い、`easy_init_newrelic_opentelemetry`というcrateを公開しました。

https://crates.io/crates/easy_init_newrelic_opentelemetry

`easy_init_newrelic_opentelemetry`を使用することで、以下のように、簡単にtracingとOpenTelemetryをセットアップすることができます。

```rust
easy_init_newrelic_opentelemetry::NewRelicSubscriberInitializer::default()
    .newrelic_license_key(&newrelic_license_key)
    .newrelic_service_name("axum-newrelic-example")
    .host_name("localhost")
    .init()
    .expect("Failed to initialize OpenTelemetry");
```

自分用に作成したものなので拡張性に乏しくフォーマットや設定事項が決め打ちですが、簡単にセットアップできるので、興味があれば使ってみてください。

## まとめ

本記事では、Rust・Axumアプリケーションの監視にNew Relicを導入し、ログとトレースを取得する方法を紹介しました。

New Relicを使うことで、ログとトレースを取得することができ、アプリケーションのパフォーマンスをモニタリングすることができます。

メトリクスについてはうまく取得できていないので、今後の取得できるようにしたいです。