はじめに
以前の記事「New Relicで実現するRust・Axumアプリのトレースとログ管理」では、Rust(Axum)アプリに OpenTelemetry 経由で New Relic を導入し、ログ・トレース・トランザクションの取得までを紹介しました。
本記事はその続編です。前回の構成で本ブログ(Leptos + Axum)を運用していたところ、NR を導入した当初は Transactions 画面にルート別のトランザクションが正しく表示されており、問題なく動作していました。
ところがある日、Transactions 画面を開くと Transaction Traces が一切紐づかなくなっていることに気づきました。調べてみると、NR が 2025年に OpenTelemetry サービスの APM 体験を刷新し(APM + OpenTelemetry Convergence)、Transactions 画面のデフォルトが metric ベースに切り替わっていました。旧来の span ベースの画面は「Span view (Legacy)」に移動しており、こちらは段階的に廃止される予定です(ドキュメント)。Span view (Legacy) では以前と変わらずトランザクションが表示されていたため、スパン自体は正しく送れていましたが、新しい metric ベースの Transactions 画面にはトレースが紐づかない状態でした。
本記事では、この問題の原因調査から修正までを紹介します。
症状
metric ベースに移行後の Transactions 画面に表示されていたのは以下の状態です。
-
GET /,GET /articles/{id},GET /auth/google等 → Transaction Traces なし -
WebTransaction/server/GET→ 全リクエストのトレースがここに集約
また、NR APM の画面上部には以下の警告バナーが表示されていました。
ルート別の Transaction はメトリクスから生成されて存在するのにトレースが空、逆にトレースは全部 GET という1つの Transaction に集まっている。メトリクスとスパンで Transaction 名の決定に使われる属性が噛み合っていませんでした。
なお、Span view (Legacy) では従来どおり otel.name をもとにトランザクションが表示されており、こちらには問題がありませんでした。
Span view (Legacy) の Transactions タブ。otel.name ベースでルート別にトランザクションが表示されている
原因:スパンに http.route がなかった
NR の Transaction 名はどう決まるか
NR の OpenTelemetry 連携では、HTTP server スパンの Transaction 名を以下のフォーマットで導出します(公式ドキュメント)。
WebTransaction/server/${http.request.method} ${http.route}
旧セマンティクス(HTTP semconv 1.20)の場合は ${http.method} が使われます。
ここで重要なのは、otel.name(OpenTelemetry のスパン名)は Transaction 名には使われないということです。NR はスパンの属性 http.request.method と http.route から Transaction 名を生成します。
Transaction 名の決定フロー: 旧(spanベース)vs 新(metricベース)
修正前のコード
前回の記事で紹介した MakeSpanForHttp をそのまま使っていました。tower-http の TraceLayer にカスタムの MakeSpan 実装を渡す構成です。
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",
)
}
}
問題は2つあります。
-
http.routeがない — NR が Transaction をルート別に分類できない -
http.methodは旧セマンティクス — 新しい OTel HTTP セマンティクス(1.23+)ではhttp.request.methodが正しい
otel.name には GET /articles/{id} のような値が入っていました。旧来の span ベースの Transactions 画面(現 Span view (Legacy))ではこの otel.name から Transaction 名が導出されていたため、前回の記事の構成でも問題なく動作していました。しかし metric ベースに移行した現在の Transactions 画面では otel.name は使われず、http.request.method + http.route から Transaction 名が生成されます。
メトリクスもそもそも送れていなかった
実はスパンの属性不足に加えて、http.server.request.duration メトリクス自体が NR に送信されていませんでした。
前回の記事でも「なぜかメトリクスが送られていない」と記していましたが、原因は OTel 初期化用の自作クレート easy_init_newrelic_opentelemetry が SdkMeterProvider をグローバルにセットしていなかったことでした。メトリクスプロバイダーが未設定のため、アプリケーション側でいくら Histogram::record() を呼んでも NR には何も届いていなかったわけです。
つまり修正前の状態は以下のとおりです。
-
スパン:
http.routeなし、otel.nameのみ → 旧 span ベースでは動作していたが metric ベースでは不十分 - メトリクス: そもそも送信されていない → metric ベースの Transactions 画面が機能しない
metric ベースの Transactions を正しく動作させるには、メトリクスの送信基盤とスパンの属性の両方を修正する必要がありました。
背景:NR の Transactions が metric ベースに移行した経緯
NR は段階的に OpenTelemetry サービスの APM 体験を span ベースから metric ベースに移行してきました。
- 2022年10月: Transactions 画面に metric ベースのビューを追加。metric/span の切り替えトグルが導入された(What's New, 2022-10-14)
- 2025年1月: APM ゴールデンメトリクス(latency, error rate, throughput)の算出が span データから metrics データに移行(What's New, 2025-01-13)
- 2025年7月: APM + OpenTelemetry Convergence により、OpenTelemetry サービスの APM 体験が NR 言語エージェントと統一。旧 span ベースの画面は「Span view (Legacy)」に移動(ブログ)
移行の理由は、span データはサンプリングされるため、スループットやレスポンスタイムの集約値が不正確になるという問題です。metric ベースでは http.server.request.duration ヒストグラムから正確なパフォーマンス統計を算出できます。
現在の Transactions 画面はこの metric ベースがデフォルトで、旧来の span ベースは Span view (legacy) として残っていますが、段階的に廃止される予定です。今後 OpenTelemetry サービスを運用するなら metric ベースへの対応は必須です。
metric ベースの Transactions を正しく機能させるには以下の両方が必要です。
-
http.server.request.durationメトリクスにhttp.routeを含める(Transaction 名の生成) - トレーシングスパンにも同じ
http.routeを含める(トレースの紐づけ)
修正
修正は大きく2つです。
- メトリクス記録とスパン生成を行うミドルウェアの追加
-
easy_init_newrelic_opentelemetryの更新(SdkMeterProviderのグローバルセット)
2 については後述の easy_init_newrelic_opentelemetry の更新 で説明します。
メトリクスとスパンを統合したミドルウェア
修正前は MakeSpanForHttp(スパン生成)のみで、メトリクス記録はありませんでした。修正後は http.server.request.duration メトリクスの記録とスパン生成を1つのミドルウェア http_observability に統合しました。
static HTTP_SERVER_REQUEST_DURATION: LazyLock<Histogram<f64>> = LazyLock::new(|| {
opentelemetry::global::meter("http")
.f64_histogram("http.server.request.duration")
.with_description("Duration of HTTP server requests.")
.with_unit("s")
.build()
});
async fn http_observability(req: Request<axum::body::Body>, next: Next) -> Response {
let start = Instant::now();
let method = req.method().clone();
let url_path = req.uri().path().to_string();
let url_scheme = req.uri().scheme_str().unwrap_or("http").to_string();
let route = req
.extensions()
.get::<MatchedPath>()
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| "UNMATCHED".to_string());
let span = tracing::info_span!(
"request",
http.request.method = %method,
http.route = %route,
url.path = %url_path,
url.scheme = %url_scheme,
http.response.status_code = tracing::field::Empty,
otel.name = format!("{} {}", method, route),
otel.kind = "server",
);
let response = next.run(req).instrument(span.clone()).await;
let status = response.status().as_u16();
span.record("http.response.status_code", i64::from(status));
let duration = start.elapsed().as_secs_f64();
HTTP_SERVER_REQUEST_DURATION.record(
duration,
&[
KeyValue::new("http.request.method", method.to_string()),
KeyValue::new("http.route", route),
KeyValue::new("http.response.status_code", i64::from(status)),
],
);
response
}
修正前の MakeSpanForHttp からの主な変更点は以下のとおりです。
-
http.method→http.request.methodに変更(OTel HTTP semconv 1.23+) -
http.routeを追加(MatchedPathから取得) -
url.pathとurl.schemeを追加(OTel セマンティクスで Required) -
http.response.status_codeをレスポンス後にスパンに記録 -
http.server.request.durationメトリクスを同じhttp.route値で記録 - ルートにマッチしなかったリクエストの fallback を
"UNMATCHED"固定値に設定(URI パスを使うと高カーディナリティになるため)
Axum の MatchedPath はレイヤーから取得できるのか
Axum 0.8 では Router::layer() で適用したミドルウェアから MatchedPath を取得できます。これは直感に反するかもしれませんが、Axum 0.8 の Router::layer() は Router を外側からラップするのではなく、内部のルートハンドラにレイヤーを適用する実装になっています。
// axum-0.8.8/src/routing/mod.rs
pub fn layer<L>(self, layer: L) -> Router<S> {
map_inner!(self, this => RouterInner {
path_router: this.path_router.layer(layer.clone()),
fallback_router: this.fallback_router.layer(layer.clone()),
catch_all_fallback: this.catch_all_fallback.map(|route| route.layer(layer)),
})
}
ルーティング(MatchedPath の設定)→ レイヤー実行 → ハンドラという順序になるため、レイヤー内で MatchedPath が参照できます。ただし fallback ハンドラ(ルートにマッチしなかったリクエスト)では MatchedPath は設定されません。
OTel HTTP セマンティクスの整理
OpenTelemetry Semantic Conventions for HTTP では、HTTP server スパンに以下の属性が定義されています。
| 属性名 | 必須/推奨 | 説明 |
|---|---|---|
http.request.method |
Required | HTTP メソッド(GET, POST 等) |
http.route |
Conditionally Required | マッチしたルートテンプレート(/users/{id} 等) |
url.path |
Required | URI パス |
url.scheme |
Required |
http / https
|
スパン名の推奨フォーマットは {method} {http.route} です。
旧セマンティクス(v1.20.0)からの主な変更は以下のとおりです。
| 旧 | 新 |
|---|---|
http.method |
http.request.method |
http.status_code |
http.response.status_code |
http.url |
url.full |
http.route は新旧で変更なしです。
http.server.request.duration メトリクスにも同様に http.request.method(Required)と http.route(Conditionally Required)が定義されています。http.route はフレームワークから取得可能な場合にのみ設定し、低カーディナリティであるべきとされています。URI パスで代替してはなりません。
easy_init_newrelic_opentelemetry の更新
今回の対応の一環として、前回の記事でも紹介した OTel 初期化用の自作クレート easy_init_newrelic_opentelemetry を更新しました。
0.4.2 → 0.5.0
前述のとおり、メトリクスが NR に送信されていなかった原因はこのクレートにありました。SdkMeterProvider をグローバルにセットしていなかったため、アプリケーション側で Histogram::record() を呼んでもメトリクスが OTLP エンドポイントに送信されませんでした。NR APM にも "Required metrics are missing" という警告が出ていました。
この修正でメトリクスプロバイダー(SdkMeterProvider)とプロセスメトリクスを追加しました。BatchSpanProcessor のパニック修正(reqwest-client → reqwest-blocking-client)も含まれています。
0.5.0 → 0.6.0
主な変更は以下のとおりです。
-
anyhow→thiserrorに移行(エラー型の明確化) -
TelemetryGuardの導入 —init()の戻り値がResult<TelemetryGuard, Error>に変更。TelemetryGuardを保持している間はテレメトリが有効で、Drop 時に graceful shutdown される - ライセンスキー未設定時に OTLP エクスポーターをスキップ(ローカル開発で NR 接続なしでも起動可能に)
-
grpc-tonicfeature 削除(HTTP のみに簡素化) -
パッチ版
opentelemetry-appender-tracing2を廃止 — 前回の記事では、ログにtrace.id/span.idを付与するためにopentelemetry-appender-tracingにパッチを当てた自作クレートを使っていた。その後、公式のopentelemetry-appender-tracingにexperimental_use_tracing_span_contextfeature が追加され、同等の機能が提供されるようになったため、パッチ版への依存を削除した
// 0.5.0 以前
easy_init_newrelic_opentelemetry::NewRelicSubscriberInitializer::default()
.newrelic_service_name(&SERVER_CONFIG.new_relic_service_name)
// ...
.init()
.expect("Failed to initialize NewRelic");
// 0.6.0 以降(TelemetryGuard を保持する必要がある)
let _telemetry_guard =
easy_init_newrelic_opentelemetry::NewRelicSubscriberInitializer::default()
.newrelic_service_name(&SERVER_CONFIG.new_relic_service_name)
// ...
.init()
.expect("Failed to initialize NewRelic");
修正後の Transactions 画面
修正後、metric ベースの Transactions 画面でルート別にトランザクションが正しく表示され、Transaction Traces も紐づくようになりました。
修正後の Transactions 画面(metric ベース)。GET /, GET /articles/{id} 等ルート別に表示され、Transaction Traces も紐づいている
まとめ
NR の Transactions にトレースが表示されない原因は、以下の2点でした。
-
メトリクスが送信されていなかった —
easy_init_newrelic_opentelemetryがSdkMeterProviderをグローバルセットしておらず、http.server.request.durationが NR に届いていなかった -
スパンに
http.routeがなかった — metric ベースの Transactions 画面ではスパンのhttp.request.method+http.routeからトレースを Transaction に紐づけるが、この属性が欠落していた
旧来の span ベースでは otel.name(スパン名)から Transaction 名が導出されていたため問題にならなかったが、NR の APM + OpenTelemetry Convergence により metric ベースがデフォルトになったことで顕在化しました。
対応としては以下の2点です。
- メトリクス記録(
http.server.request.duration)とスパン生成(http.route+http.request.method)を統合したミドルウェアの追加 -
easy_init_newrelic_opentelemetryの更新(SdkMeterProviderのグローバルセット)
OTel のセマンティクスに従ってメトリクスとスパンの属性を正しく設定すれば、NR 側で特別な設定は不要です。Span view (Legacy) は段階的に廃止予定のため、今後の対応は必須です。



