🔭

Lambdalith な Rust Web サーバーで AWS OTLP エンドポイントを使ってみた

に公開

TL;DR

  • Rust を使った Lambdalith な Axum Web サーバーに、OpenTelemetry を使ったトレーシングを実装し、AWS OTLP エンドポイントを利用して CloudWatch でトレース監視をできるようにする方法を試した

はじめに

https://zenn.dev/utcarnivaldayo/articles/2025-10-20-rust-lambdalith

以前書いた本で、Hono の Lambdalith 構成を紹介した際は、Lambda を監視する方法として、Application Signal を導入していましたが、Rust では AL2023 ランタイムを利用するため、Application Signal は提供されません。また、Rust は本質的に実行バイナリビルド時に、アプリケーションの処理が決定されてしまうため、ADOT による自動計装も提供されません。

このため、Rustacean(Rust ユーザー)は一般にアプリケーションの監視について取り扱おうとすると、OpenTelemetry を直接利用して、計装を行いそのテレメトリーを適切なモニタリングバックエンドに送信する処理の実装を一定程度自力で行う必要が出てきます。

手動による実装は、自動計装が利用可能な Node.js や Python, Java などの言語と比較して明確なデメリットにはなります。しかしながら、必要な部分だけを計測し、ログやトレースの量を制御することが可能という観点では、マニュアルでの計装が優れている点もあります。そして、何より OpenTelemetry を深く理解し、ポータブルな技術知識を得る良い機会となります。

Rust では OpenTelemetry の公式からRust の OpenTelemetry クレートが提供されておりこれを利用することになります。

しかし、OpenTelemetry を Rust で学ぶ上の少々困った点として、OpenTelemetry クレートは2024年10月の v0.28.0 における破壊的変更のために、少し古い Rust & OpenTelemetry について扱った技術記事では動かないサンプルコードが散見され、新規の学習者を混乱させる要因となっています。

このため、本記事では Rust における OpenTelemetry の実装を最新の API(2025年10月現在 v0.31.0)のケースで行います。そして、そのテレメトリを Lambda 関数の実行環境から AWS OTLP エンドポイントに送信して CloudWatch 上で確認を行うところまでの実装を試してみます。

本記事のソースコードは下記のリポジトリで公開します。

https://github.com/utcarnivaldayo/otlp-endpoint-lambda-rust

本記事で扱うこと

以上の課題を踏まえて、本記事では次のことを扱います。

  1. Rust における OpenTelemetry を学び・実装してみること
  2. OTLP エンドポイントの有効化に関する設定を Pulumi で実装すること
  3. AWS OTLP エンドポイントにテレメトリ送信をして、CloudWatch でトレースを確認すること

具体的には下記のような AWS 構成および Rust における OpenTelemetry の実装を行います。


OTLP 検証のための AWS サーバレス構成図


Rust & Lambda & OpenTelemetry のコンポーネント関連図

前提として、OpenTelemetry や オブザーバビリティとは何か?の基礎知識は、OpenTelemetry の公式ドキュメントで詳しい説明があるので、一読しておくことをお勧めします。本記事では、OpenTelemetry の基本概念などについては説明しません。

https://opentelemetry.io/ja/docs/what-is-opentelemetry/

https://opentelemetry.io/ja/docs/concepts/observability-primer/

OpenTelemetry の概要について網羅的に知りたい方は 書籍「入門 OpenTelemetry」の1章〜4章を通読するとより理解が進むと思います。

https://www.oreilly.co.jp/books/9784814401024/

Rust のシンプルなトレーシング

Rust で OpenTelemetry の実装に入る前に、Rust で「ロギング」と「トレース」を実現するには、より手頃な手段が知られています。それが、Rust の非同期処理ランタイムの tokio のエコシステムの1つとして開発されている、tracing クレートです。

https://github.com/tokio-rs/tracing

tracing クレート の概要については下記の記事に譲ることにします。

https://zenn.dev/taiki45/books/pragmatic-rust-application-development/viewer/tracing

tracing クレートの追加を行い、早速実装をしてみます。

Cargo.toml
...
 [dependencies]
 tokio = { version = "1", features = ["full"] }
 axum = { version = "0.8", features = ["macros"] }
 tower-http = { version = "0.6", features = ["trace", "cors"] }
 serde = { version = "1", features = ["derive"] }
 anyhow = "1"
 utoipa = { version = "5", features = ["yaml"] }
 utoipa-axum = "0.2"
 utoipa-scalar = { version = "0.3", features = ["axum"] }
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["json", "local-time", "env-filter"] }
 
...

tracing クレートで計装してみる

計装とは、対象となるシステムやコンポーネントに対して計測器(電圧計や温度計、圧力計のような計器類)を取り付けることを意味します。tracing における計装は、主に関数に対して計装を行い、その関数の実行時間や呼び出し場所、引数、返り値などをテレメトリデータとして収集する目的のために行われます。

tracingクレートでは、一番シンプルな計装の手段として#[tracing::instrument]マクロを利用して関数に計装を行うことが可能です。
ret属性を付与することで戻り値をログ出力できるようにします。

main.rs
...
+#[tracing::instrument(ret)]
 async fn hello() -> &'static str
...
+   #[tracing::instrument(ret)]
    fn try_new(person: String, message: String) -> Result<Self, GreetContentError>
...
+   #[tracing::instrument(ret)]
    fn create_greeting(greet_content: &GreetContent) -> Self
...
+#[tracing::instrument(ret)]
 async fn greet(
     Json(payload): Json<GreetContent>,
 ) -> Result<(StatusCode, Json<GreetResponse>), (StatusCode, String)>

しかしながら、この計装を行った状態で関数を呼びだしても、トレースは生成されません。
これは計装において期待された動作です。「実際にトレースを出力する」という処理を導入しない限りは、計装によるオーバーヘッドが発生しないように設計されているためです。
これは、後述する OpenTelemetry でも同様です。

トレースを出力してみる

tracingクレートを利用して行った計装は、tracing-subscriberを利用することで、実際の出力フォーマットや付与する属性を含めて標準出力やファイルへ書き出すことができます。

main関数の最初に tracing_subscriber の設定を追記します。

use tracing_subscriber::layer::SubscriberExt;
let subscriber = tracing_subscriber::registry()
    .with(
        tracing_subscriber::fmt::layer()
            .json()
            .with_level(true)
            .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE)
            .with_timer(
                tracing_subscriber::fmt::time::OffsetTime::local_rfc_3339()
                    .expect("Failed to create tracing subscriber timer"),
            )
            .with_file(true)
            .with_line_number(true)
            .with_thread_ids(true)
            .with_current_span(true)
            .with_ansi(false)
            .with_target(true)
            .with_writer(std::io::stdout),
    );
tracing::subscriber::set_global_default(subscriber)
   .expect("Failed to set tracing subscriber");

.with_* メソッドの設定項目については下記の通りです。

  • .json(): 出力フォーマットをJSON形式に設定
  • .with_level(true): ログレベル(INFO、WARN、ERRORなど)を出力に含める
  • .with_span_events(FmtSpan::ACTIVE): アクティブなスパンのイベント(開始・終了)を記録
  • .with_timer(): ローカル時間でタイムスタンプの形式を RFC 3339 に設定
  • .with_file(true): ログが出力されたファイル名を含める
  • .with_line_number(true): ログが出力された行番号を含める
  • .with_thread_ids(true): スレッドIDを出力に含める
  • .with_current_span(true): 現在のスパン情報を含める
  • .with_ansi(false): ANSI色コードを無効化(Lambda環境ではカラー出力不要)
  • .with_target(true): ログのターゲット(モジュール名)を含める
  • .with_writer(std::io::stdout): 出力先を標準出力に設定

https://zenn.dev/scirexs/articles/c467a911218593

https://docs.rs/tracing-subscriber/latest/tracing_subscriber/fmt/struct.Layer.html

トレースを CloudWatch で確認

tracing_subscriberの実装をした Lambda 関数をデプロイし、greet関数を実行した時、標準出力は CloudWatch logs に転送されます。前回の記事で、Lambda のリソースの Pulumi で記述し、CloudWatch logs が自動で生成されるようになっていますので、そのログストリームを確認すると下記のように表示されます。


tracing を CloudWatch logs で確認

下記はトレースの一例として、try_newメソッドのスパンにおける、retで指定したスパンイベントを表しています。

補足として、スパンというのは下記の画像(New relic 記事から引用)の時間的「幅」をもつやつで、スパンイベントというのはスパン内で発生した「点」のログです。

https://newrelic.com/jp/blog/how-to-relic/dude-wheres-my-error

{
    "timestamp": "2025-10-22T17:31:37.295195102+09:00",
    "level": "INFO",
    "fields": {
        "return": "Ok(GreetContent { person: \"山田太郎\", message: \"お元気ですか?\" })"
    },
    "target": "api",
    "filename": "src/main.rs",
    "line_number": 51,
    "span": {
        "message": "お元気ですか?",
        "person": "山田太郎",
        "name": "try_new"
    },
    "spans": [
        {
            "requestId": "8a7b8a27-b694-4055-a015-b50a83fa0d6e",
            "xrayTraceId": "Root=1-68f89669-6156423963320fd131333d05;Parent=426158068be90b12;Sampled=0;Lineage=1:4987071b:0",
            "name": "Lambda runtime invoke"
        },
        {
            "payload": "GreetContent { person: \"山田太郎\", message: \"お元気ですか?\" }",
            "name": "greet"
        },
        {
            "message": "お元気ですか?",
            "person": "山田太郎",
            "name": "try_new"
        }
    ],
    "threadId": "ThreadId(1)"
}

ここで、spansは親のスパンを表しますが、"name": "Lambda runtime invoke"は私たちの手で実装していません。
実はこのスパンは、lambda_httpの内部で利用される、lambda_runtimeクレート内で計装(tracing::info_span!)されており、Lambda 関数の呼びだしに際してスパンが生成されるようになっています。

https://github.com/awslabs/aws-lambda-rust-runtime/blob/main/lambda-runtime/src/layers/trace.rs#L58-L71

Rust で OpenTelemetry トレース実装

ここまでで、tracing クレートを導入し、CloudWatch logs にトレースとログ(スパンイベント)を出力することができました。

しかしながら、tracing クレートのトレースのフォーマットでは、構造化ログ & スパンを追跡できるようにはなっているものの、任意のオブザーバビリティバックエンドでこの情報を可視化・解析することはできません。

また、tracing クレートのトレースは 分散トレースではないため、1つのトレース(単一リクエスト)が複数のマイクロサービスを利用して処理を行っている場合は、マイクロサービス間のスパンを追う手段もありません。

このような問題を解決するのが、OpenTelemetry です。

この tracing クレートですが、幸いなことに tracing-opentelemetryというクレートを利用して OpenTelemetry に橋渡しすることが可能です。

必要な OpenTelemetry クレートを追加し、下記のように Cargo.toml は更新されます。

Cargo.toml
 [package]
 name = "api"
 version = "0.1.0"
 edition = "2024"
 
 [dependencies]
 tokio = { version = "1", features = ["full"] }
 axum = { version = "0.8", features = ["macros"] }
 tower-http = { version = "0.6", features = ["trace", "cors"] }
 serde = { version = "1", features = ["derive"] }
 anyhow = "1"
 utoipa = { version = "5", features = ["yaml"] }
 utoipa-axum = "0.2"
 utoipa-scalar = { version = "0.3", features = ["axum"] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["json", "local-time", "env-filter"] }
+opentelemetry = "0.31"
+opentelemetry_sdk = { version = "0.31", features = ["rt-tokio"] }
+opentelemetry-otlp = { version = "0.31", features = ["trace", "metrics", "logs", "grpc-tonic"] }
+opentelemetry-semantic-conventions = { version = "0.31", features = ["semconv_experimental"] }
+opentelemetry-appender-tracing = "0.31"
+tracing-opentelemetry = "0.32"
+opentelemetry-http = "0.31"
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+uuid = { version = "1", features = ["v4"] }
 
 [dependencies.lambda_http]
 version = "0.17"
 optional = true
 default-features = false
 features = [ "apigw_http", "opentelemetry" ]
 
+[dev-dependencies]
+opentelemetry-stdout = "0.31"
 
+[build-dependencies]
+git-url-parse = "0.6"
 
 [features]
 lambda = ["lambda_http"]

OpenTelemetry と tracing クレートの関係性については下記の ymgyt さんの下記のブログ記事でcrate間の関係が整理されています。

https://blog.ymgyt.io/entry/understanding-opentelemetry-tracer-configuration-from-specifications/

OpenTelemetry コンポーネントの整理

前節で純粋なtracing_subscriberの実装は完了しているので、本格的に OpenTelemetry の実装を進めていきます。
ここから、OpenTelemetry の登場人物が多くなるため下記の図に整理しました。

ここで、図の矢印はコンポーネントの参照先を表します。基本的には自身のコンポーネントはテレメトリの転送先のコンポーネントに依存していると考えると、理解しやすいかと思います。

OTel Components は Rust アプリケーション内に登場する OpenTelemetry の概念をまとめてグルーピングしたもので筆者が独自につけたものです。OTel Collector は Lambda layer を利用する場合 OpenTelemetry Lambda Layer として適宜読み替えてもらって問題ありません。


(再掲)Rust & Lambda & OpenTelemetry のコンポーネント関連図

以降は、tracing_subscriberの OpenTelemetry への橋渡し部分(Trace Layer)と図の OTel Components の区画について注目し、アプリケーションの実装を進めていきます。

コンポーネント実装順序の検討

上記の図からトレースの実装を行うには、いくつかのコンポーネントについて扱わなければなりませんが、どのような順番でコンポーネントの理解・実装を進めるのが良いでしょうか?

私がおすすめする方針としては、「コンポーネントの参照関係」を辿るように実装するのが良いと思います。具体的には、OTel Components の区画において、Span Expoter が末端のコンポーネントとなり、Span Expoter と OTel Collector はネットワークを跨いだ依存関係となるため疎結合になっています。つまり、Span Expoter から実装を始める場合は実質的に他へのコンポーネントを考える必要なく実装できます。

アンチパターンとして、テレメトリのデータ転送の順で tracing_subscriber 側から実装を進めると、最初から多くの OpenTelemetry コンポーネントの理解と実装に向き合わなくてはならなくなるため、挫折しやすくなる可能性が高いです。

以上から、Span Expoter から tracing_subscriber 側へ遡るような順番で実装を進める方針を取ります。

Span Exporter と Tracer Provider の初期化

Span Exporter と Tracer Provider の初期化処理は下記のような関数で記述できます。
ここで、引数のresourceが具体的にどのように与えられるかはこの後すぐに説明するのでひとまずおいておきます。

otel.rs
pub fn init_tracer_provider(
    resource: opentelemetry_sdk::Resource,
) -> opentelemetry_sdk::trace::SdkTracerProvider {
    use opentelemetry_otlp::SpanExporter;
    use opentelemetry_otlp::WithExportConfig;
    use std::time::Duration;
    let span_exporter: SpanExporter = SpanExporter::builder()
        .with_tonic()
        .with_endpoint("http://localhost:4317")
        .with_protocol(opentelemetry_otlp::Protocol::Grpc)
        //.with_timeout(opentelemetry_otlp::OTEL_EXPORTER_OTLP_TIMEOUT_DEFAULT)
        .with_timeout(Duration::new(3, 0))
        .build()
        .expect("Failed to create OTLP exporter");

    // let span_exporter = opentelemetry_stdout::SpanExporter::default();

    // otel tracer
    opentelemetry_sdk::trace::SdkTracerProvider::builder()
        // .with_simple_exporter(span_exporter)
        .with_sampler(opentelemetry_sdk::trace::Sampler::AlwaysOn)
        .with_id_generator(opentelemetry_sdk::trace::RandomIdGenerator::default())
        .with_resource(resource)
        .with_batch_exporter(span_exporter)
        .build()
}

SpanExporter の設定解説

Span Expoter は、Rust アプリケーション外部の OpenTelemetry Collector やオブザーバビリティバックエンドに対して、スパンを送信するコンポーネントです。実装としては、Rust ではopentelemetry_otlpクレートにより、SpanExpoterは提供されています。

SpanExpoterHTTPgRPCの2つのプロトコルが提供されていますが、今回は高速な通信を重視するため.with_tonicメソッドを利用して、Rust の gRPC フレームワークであるtonicを Span Expoter のバックエンドに利用して、外部の Collecter (OpenTelemetry Lambda Layer)へ、スパンの転送を行うように設定します。

HTTP (バイナリ)形式の場合は、opentelemetry_otlpクレートの example を参照すると下記のように設定可能です。

https://github.com/open-telemetry/opentelemetry-rust/blob/main/opentelemetry-otlp/examples/basic-otlp-http/src/main.rs#L42-L53

プロトコル(gRPC or HTTP)や、圧縮方式(gzip や zstd)などの Span Expoter のバックエンドを変更したい場合は、opentelemetry_otlpクレートで下記のような feature flag が利用可能です。

https://github.com/open-telemetry/opentelemetry-rust/blob/main/opentelemetry-otlp/Cargo.toml#L61-L99

.with_endpointを利用して、Collector の gRPC のデフォルトのエンドポイントである、http://localhost:4317を指定しています。

タイムアウトについては、std::time::Duration型で指定可能です。今回は手動で 3[s] としていますが、
opentelemetry_otlp::OTEL_EXPORTER_OTLP_TIMEOUT_DEFAULTでデフォルト値 10[s] を設定することも可能です。

TracerProvider の設定解説

TracerProvider は、転送するトレース(スパン単位ではない)を決定するサンプリング方法、トレースやスパンに ID を与えるなど、トレースを作成するための設定を与えます。
実装としては、opentelemetry_sdkクレートにより提供されています。

.with_samplerを利用して、AlwaysOnでサンプリングをせずに全てのトレースを利用するようにします。TraceIdRatioBasedを利用すれば、比率によって利用するトレースをサンプリングすることも可能ですが、今回は少数のトレースを扱うため、トレースが利用できないケースがあると困るので、AlwaysOnとしています。

サンプリング戦略については、サンプリングをすべきケースしないケースについて OpenTelemtry の記事に記載されています。

https://opentelemetry.io/ja/docs/concepts/sampling/

.with_id_generatorではRandomIdGeneratorの指定で内部的にSmallRngを利用した ID が生成されます。
.with_resourceで Resource を設定しますが、詳細は次の節で紹介します。
.with_batch_exporterを利用して、BatchSpanProcessorをセットアップします。BatchSpanProcessor は専用のバックグラウンドスレッドを利用して、非同期的にスパンをエクスポートします。
これらの設定により、TracerProvider を作成可能です。

https://docs.rs/opentelemetry_sdk/latest/opentelemetry_sdk/trace/struct.TracerProviderBuilder.html#method.with_batch_exporter

Resource と Detector

OpenTelemetry における Resouce とは、簡潔に述べるとテレメトリの「発生源」を表すメタ情報です。

仕様としては、OpenTelemetry Semantic conventions が定められており、「発生源」毎にどのような属性が利用できるか?付与必須・推奨の属性か?などの情報が OpenTelemtry から提供されています。

例えば、faas (lambda)実行環境であれば、下記のページで属性情報が提供されています。

https://opentelemetry.io/docs/specs/semconv/resource/faas/

Resource は「発生源」によって利用する属性が定められるため、Resource を「発生源」に応じて作成できると便利です。
このような、用途のためにopentelemetry_sdkResourceDetectorトレイトを提供しています。

https://docs.rs/opentelemetry_sdk/0.31.0/opentelemetry_sdk/resource/trait.ResourceDetector.html

この ResourceDetector の Lambda 環境向け実装としては、opentelemetry_awsにて LambdaResourceDetectorが提供されています。

https://docs.rs/opentelemetry-aws/0.19.0/opentelemetry_aws/detector/struct.LambdaResourceDetector.html

実装の中身を覗いてみると、先述のfaas の Resource 属性と同じものが付与されていることが確認できます。

https://github.com/open-telemetry/opentelemetry-rust-contrib/blob/main/opentelemetry-aws/src/detector/lambda.rs#L19-L56

また、属性の値については Lambda の環境変数を利用して与えられていることも確認できます。

意外とシンプルな実装であることが確認できます。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime

カスタム Lambda Resource Detector

先述のopentelemetry_awsクレートのLambdaResourceDetectorを使うことで、Resource の作成はできるのですが、独自に追加したい属性などがあると思います。例えば、デプロイ環境(staging や production)を表す属性や、git のブランチやコミットハッシュを属性として与えたいというケースが挙げられます。

このため、今回は、既存のLambdaResourceDetectorを利用せず自作のLambdaResourceDetectorを作成します。(既存のLambdaResourceDetectorの実装は部分的に流用します)属性としてはFaaSCloudServiceTelemetry SDKDeployment EnvironmentVCSを実装することにします。

属性のキー名はopentelemetry_semantic_conventionsクレートによって提供されており、Resouece のキーは定数値として下記の一覧に記載されています。

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

otel.rs
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::resource::ResourceDetector;

pub struct LambdaResourceDetector;

impl ResourceDetector for LambdaResourceDetector {
    fn detect(&self) -> Resource {
        let attributes = [
            lambda_resource_attributes(),
            deployment_environment_resource_attributes(),
            service_resource_attributes(),
            telemetry_sdk_resource_attributes(),
            vcs_resource_attributes(),
        ]
        .concat();

        let resource = opentelemetry_sdk::Resource::builder_empty()
            .with_schema_url(attributes, opentelemetry_semantic_conventions::SCHEMA_URL)
            .build();
        resource
    }
}

fn lambda_resource_attributes() -> Vec<opentelemetry::KeyValue> {
    // lambda
    let lambda_name: String = std::env::var("AWS_LAMBDA_FUNCTION_NAME").unwrap_or_default();
    if lambda_name.is_empty() {
        return vec![];
    }
    let aws_region: String = std::env::var("AWS_REGION").unwrap();
    let function_version: String = std::env::var("AWS_LAMBDA_FUNCTION_VERSION").unwrap();
    let function_memory_limit: i64 = std::env::var("AWS_LAMBDA_FUNCTION_MEMORY_SIZE")
        .map(|s: String| s.parse::<i64>().unwrap_or_default() * 1024 * 1024)
        .unwrap_or_default();
    let instance: String = std::env::var("AWS_LAMBDA_LOG_STREAM_NAME").unwrap_or_default();
    let log_group_name: String = std::env::var("AWS_LAMBDA_LOG_GROUP_NAME").unwrap_or_default();
    let lambda_arn : String = std::env::var("API_LAMBDA_ARN").unwrap_or_default();

    use opentelemetry::{Array, KeyValue, StringValue, Value};
    let attributes: Vec<KeyValue> = vec![
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::CLOUD_PROVIDER,
            "aws",
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::CLOUD_PLATFORM,
            "aws_lambda",
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::CLOUD_REGION,
            aws_region,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::CLOUD_RESOURCE_ID,
            lambda_arn,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::FAAS_INSTANCE,
            instance,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::FAAS_NAME,
            lambda_name,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::FAAS_VERSION,
            function_version,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::FAAS_MAX_MEMORY,
            function_memory_limit,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::AWS_LOG_GROUP_NAMES,
            Value::Array(Array::from(vec![StringValue::from(log_group_name)])),
        ),
    ];
    attributes
}

fn service_resource_attributes() -> Vec<opentelemetry::KeyValue> {
    use uuid::Uuid;
    // service
    let service_name: &str = env!("CARGO_PKG_NAME");
    let service_version: &str = env!("CARGO_PKG_VERSION");
    let service_namespace: &str = env!("PROJECT_NAME");
    let service_instance_id: Uuid = Uuid::new_v4();

    use opentelemetry::KeyValue;
    let attributes: Vec<KeyValue> = vec![
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::SERVICE_NAME,
            service_name,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::SERVICE_VERSION,
            service_version,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::SERVICE_NAMESPACE,
            service_namespace,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::SERVICE_INSTANCE_ID,
            service_instance_id.to_string(),
        ),
    ];
    attributes
}

fn telemetry_sdk_resource_attributes() -> Vec<opentelemetry::KeyValue> {
    let telemetry_sdk_name: &str = env!("TELEMETRY_SDK_VERSION");
    use opentelemetry::KeyValue;
    let attributes: Vec<KeyValue> = vec![
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::TELEMETRY_SDK_NAME,
            "opentelemetry",
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::TELEMETRY_SDK_LANGUAGE,
            "rust",
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::TELEMETRY_SDK_VERSION,
            telemetry_sdk_name,
        ),
    ];
    attributes
}

fn deployment_environment_resource_attributes() -> Vec<opentelemetry::KeyValue> {
    // deployment environment
    let deployment_environment_name: String = env!("PULUMI_STACK").to_string();

    use opentelemetry::KeyValue;
    let attributes: Vec<KeyValue> = vec![KeyValue::new(
        opentelemetry_semantic_conventions::resource::DEPLOYMENT_ENVIRONMENT_NAME,
        deployment_environment_name,
    )];
    attributes
}


fn vcs_resource_attributes() -> Vec<opentelemetry::KeyValue> {
    // vcs
    let vcs_ref_head_name: &str = env!("VCS_REF_HEAD_NAME");
    let vcs_ref_head_revision: &str = env!("VCS_REF_HEAD_REVISION");
    let vcs_ref_head_type: &str = "branch";
    let vcs_repository_name: &str = env!("VCS_REPOSITORY_NAME");
    let vcs_repository_url_full: &str = env!("VCS_REPOSITORY_URL_FULL");

    use opentelemetry::KeyValue;
    let attributes: Vec<KeyValue> = vec![
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::VCS_REF_HEAD_NAME,
            vcs_ref_head_name,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::VCS_REF_HEAD_REVISION,
            vcs_ref_head_revision,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::VCS_REF_TYPE,
            vcs_ref_head_type,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::VCS_REPOSITORY_NAME,
            vcs_repository_name,
        ),
        KeyValue::new(
            opentelemetry_semantic_conventions::resource::VCS_REPOSITORY_URL_FULL,
            vcs_repository_url_full,
        ),
    ];
    attributes
}

コード内で独自の環境変数に対してenv!マクロを利用しているため、build.rs ファイルで、ビルド時に環境変数を埋め込みます。build.rsファイルは、srcディレクトリ下でなく、apiのディレクトリ直下に作成してください。

build.rs
use std::process::{Command, Output};

fn vcs_ref_head_name() {
    const VCS_REF_HEAD_NAME: &str = "VCS_REF_HEAD_NAME";
    let output: Output = Command::new("git")
        .args(&["rev-parse", "--abbrev-ref", "HEAD"])
        .output()
        .expect("failed to execute git");
    let vcs_ref_head_name: String = String::from_utf8(output.stdout).unwrap();
    println!(
        "cargo:rustc-env={}={}",
        VCS_REF_HEAD_NAME,
        vcs_ref_head_name.trim()
    );
}

fn vcs_ref_head_revision() {
    const VCS_REF_HEAD_REVISION: &str = "VCS_REF_HEAD_REVISION";
    let output: Output = Command::new("git")
        .args(&["rev-parse", "HEAD"])
        .output()
        .expect("failed to execute git");
    let vcs_ref_head_revision: String = String::from_utf8(output.stdout).unwrap();
    println!(
        "cargo:rustc-env={}={}",
        VCS_REF_HEAD_REVISION,
        vcs_ref_head_revision.trim()
    );
}

fn vcs_repository_url_full() {
    use git_url_parse::GitUrl;
    use git_url_parse::types::provider::GenericProvider;
    const VCS_REPOSITORY_URL_FULL: &str = "VCS_REPOSITORY_URL_FULL";
    const VCS_REPOSITORY_NAME: &str = "VCS_REPOSITORY_NAME";
    let output: Output = Command::new("git")
        .args(&["config", "--get", "remote.origin.url"])
        .output()
        .expect("failed to execute git");

    if output.stdout.is_empty() {
        println!(
            "cargo:rustc-env={}={}",
            VCS_REPOSITORY_URL_FULL,
            ""
        );
        println!(
            "cargo:rustc-env={}={}",
            VCS_REPOSITORY_NAME,
            ""
        );
        return;
    }

    let git_remote_url: GitUrl = GitUrl::parse(&String::from_utf8(output.stdout).unwrap().trim()).unwrap();
    let generic_provider: GenericProvider = git_remote_url.provider_info().unwrap();
    println!(
        "cargo:rustc-env={}={}",
        VCS_REPOSITORY_URL_FULL,
        format!("https://github.com/{}", generic_provider.fullname())
    );
    println!(
        "cargo:rustc-env={}={}",
        VCS_REPOSITORY_NAME,
        generic_provider.repo()
    );
}

fn project_name() {
    const PROJECT_NAME: &str = "PROJECT_NAME";
    let project_name: String =
        std::env::var(PROJECT_NAME).unwrap_or(String::new());
    println!("cargo:rustc-env={}={}", PROJECT_NAME, project_name.trim());
}

fn pulumi_stack() {
    const PULUMI_STACK: &str = "PULUMI_STACK";
    let pulumi_stack: String = std::env::var(PULUMI_STACK).unwrap_or("dev".to_string());
    println!("cargo:rustc-env={}={}", PULUMI_STACK, pulumi_stack.trim());
}

fn telemetry_sdk_version() {
    const TELEMETRY_SDK_VERSION: &str = "TELEMETRY_SDK_VERSION";
    let telemetry_sdk_version: &str = "0.31.0";
    println!(
        "cargo:rustc-env={}={}",
        TELEMETRY_SDK_VERSION, telemetry_sdk_version.trim()
    );
}

fn api_base_path() {
    const API_BASE_PATH: &str = "API_BASE_PATH";
    let api_base_path: String = std::env::var(API_BASE_PATH).unwrap_or("/api".to_string());
    println!("cargo:rustc-env={}={}", API_BASE_PATH, api_base_path.trim());
}

fn api_lambda_arn() {
    const API_LAMBDA_ARN: &str = "API_LAMBDA_ARN";
    let api_lambda_arn: String = std::env::var(API_LAMBDA_ARN).unwrap_or(String::new());
    println!("cargo:rustc-env={}={}", API_LAMBDA_ARN, api_lambda_arn.trim());
}

fn remote_endpoint() {
    const REMOTE_ENDPOINT: &str = "REMOTE_ENDPOINT";
    let remote_endpoint: String =
        std::env::var(REMOTE_ENDPOINT).unwrap_or("http://localhost:3030".to_string());
    println!("cargo:rustc-env={}={}", REMOTE_ENDPOINT, remote_endpoint.trim());
}

fn main() {
    vcs_ref_head_name();
    vcs_ref_head_revision();
    vcs_repository_url_full();
    pulumi_stack();
    project_name();
    telemetry_sdk_version();
    api_base_path();
    api_lambda_arn();
    remote_endpoint();
}

Resource を初期化する関数は次のようになります。

otel.rs
pub fn init_resource() -> opentelemetry_sdk::Resource {
    let detector: LambdaResourceDetector = LambdaResourceDetector;
    let resource: opentelemetry_sdk::Resource = detector.detect();
    resource
}

Tracer の初期化

前節で、TracerPrivider を作成することができるようになったので、Tracer を TracerProvider から作成します。Tracer はスパンの開始や管理を責務とします。

Tracer は Scope の情報を与えることで生成できます。
Scope を生成する関数init_scopeは次節で紹介します。

otel.rs
pub fn init_tracer(
    tracer_provider: &opentelemetry_sdk::trace::SdkTracerProvider,
) -> opentelemetry_sdk::trace::SdkTracer {
    use opentelemetry::InstrumentationScope;
    use opentelemetry::trace::TracerProvider;
    let scope: InstrumentationScope = init_scope();
    tracer_provider.tracer_with_scope(scope)
}

Scope の初期化

Scope は計装した自身のクレート情報をメタ情報を管理します。
Rust が予約済みの環境変数CARGO_PKG_NAMECARGO_PKG_VERSIONを利用して、クレート名とクレートバージョンを付与します。

.with_schema_urlOpenTelemetry Semantic conventions のスキーマ情報が URL として https://opentelemetry.io/schemas/1.36.0 (リンクに飛ぶとスキーマ情報を DL できます)のように提供されているため指定します。この URL はopentelemetry_semantic_conventionsクレートがSCHEMA_URLの定数値を提供しています。

otel.rs
pub fn init_scope() -> opentelemetry::InstrumentationScope {
    opentelemetry::InstrumentationScope::builder(env!("CARGO_PKG_NAME"))
        .with_version(env!("CARGO_PKG_VERSION"))
        .with_schema_url(opentelemetry_semantic_conventions::SCHEMA_URL)
        .build()
}

OpenTelemetry と tracing_subscriber の繋ぎ込み


(再掲)Rust & Lambda & OpenTelemetry のコンポーネント関連図

前節で Tracer までの作成が完了し、図内の OTel Components の実装が完了しました。本節では、tracing_subscriber の区画に移り、tracing クレートと OpenTelemtry の繋ぎ込みを行います。

traing から OpenTelemetry への変換は、tracing_subscriber の layer を利用して行います。(EnvFilterもしれっと追加します )実装は下記のとおりです。

Logger Provider もおまけに作成

ほぼ TracerProvider と同様なのでついでに作成してしまいます。

pub fn init_logger_provider(
    resource: opentelemetry_sdk::Resource,
) -> opentelemetry_sdk::logs::SdkLoggerProvider {
    use opentelemetry_otlp::LogExporter;
    use opentelemetry_otlp::WithExportConfig;
    use std::time::Duration;
    let log_exporter: LogExporter = opentelemetry_otlp::LogExporter::builder()
        .with_tonic()
        .with_endpoint("http://localhost:4317")
        .with_protocol(opentelemetry_otlp::Protocol::Grpc)
        .with_timeout(Duration::new(3, 0))
        //.with_timeout(opentelemetry_otlp::OTEL_EXPORTER_OTLP_TIMEOUT_DEFAULT)
        .build()
        .expect("Failed to create OTLP log exporter");

    // let log_exporter = opentelemetry_stdout::LogExporter::default();

    opentelemetry_sdk::logs::SdkLoggerProvider::builder()
        .with_resource(resource)
        .with_batch_exporter(log_exporter)
        .build()
}

ログについて、tracing から OpenTelemtry へ変換するにはopentelemetry_appender_tracingクレートを利用します。
変換処理の実装については下記のinit_tracing_subscriberを参照してください。

otel.rs
pub fn init_tracing_subscriber(
    tracer_provider: &opentelemetry_sdk::trace::SdkTracerProvider,
    logger_provider: &opentelemetry_sdk::logs::SdkLoggerProvider,
) {
    use tracing_subscriber::layer::SubscriberExt;
    let tracer = init_tracer(tracer_provider);
    let tracer_layer = tracing_opentelemetry::layer().with_tracer(tracer);

    use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
    let logger_layer = OpenTelemetryTracingBridge::new(logger_provider);

    let subscriber = tracing_subscriber::registry()
        .with(
            tracing_subscriber::filter::EnvFilter::builder()
                .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
                .from_env_lossy(),
        )
        .with(
            tracing_subscriber::fmt::layer()
                .json()
                .with_level(true)
                .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE)
                .with_timer(
                    tracing_subscriber::fmt::time::OffsetTime::local_rfc_3339()
                        .expect("Failed to create tracing subscriber timer"),
                )
                .with_file(true)
                .with_line_number(true)
                .with_thread_ids(true)
                .with_current_span(true)
                .with_ansi(false)
                .with_target(true)
                .with_writer(std::io::stdout),
        )
        .with(tracer_layer)
        .with(logger_layer);
    tracing::subscriber::set_global_default(subscriber).expect("Failed to set tracing subscriber");
}

エントリポイントへ OpenTelemtry を反映

前節までで、OpenTelemetry を利用するための準備は完了しました。
本節で、main.rs について、ファイル分割した hello.rs と otel.rs を利用した実装に修正します。

hello.rsotel.rsの全体は下記のリポジトリを参照してください。

https://github.com/utcarnivaldayo/otlp-endpoint-lambda-rust/tree/main/api/src

main.rs
mod hello;
mod otel;

use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(info(
    title = env!("PROJECT_NAME"),
    version = env!("CARGO_PKG_VERSION"),
    description = "sample api description",
))]
struct ApiDocs;

#[tokio::main]
async fn main() -> anyhow::Result<()> {

    let resouce: opentelemetry_sdk::Resource = otel::init_resource();
    let tracer_provider: opentelemetry_sdk::trace::SdkTracerProvider =
        otel::init_tracer_provider(resouce.clone());
    let logger_provider: opentelemetry_sdk::logs::SdkLoggerProvider = otel::init_logger_provider(resouce);
    otel::init_tracing_subscriber(&tracer_provider, &logger_provider);

    const API_BASE_PATH: &str = env!("API_BASE_PATH");
    // クレートバージョンが 0.1.2 ならば、メジャーバージョンは 0
    let api_major_version: usize = env!("CARGO_PKG_VERSION")
        .split('.')
        .next()
        .unwrap()
        .parse()
        .unwrap();

    use utoipa_axum::router::OpenApiRouter;
    let api_base_path = format!("{}/v{}",API_BASE_PATH, api_major_version);
    let (api_router, api_docs) = OpenApiRouter::with_openapi(ApiDocs::openapi())
        .nest(api_base_path.as_str(), hello::create_hello_router())
        .split_for_parts();

    use utoipa_scalar::{Scalar, Servable};
    let app_router = axum::Router::new()
        .merge(api_router)
        .merge(Scalar::with_url(format!("{}/docs", API_BASE_PATH), api_docs))
        .layer(tower_http::cors::CorsLayer::permissive());

    #[cfg(not(feature = "lambda"))]
    {
        use tokio::net::TcpListener;
        let listener: TcpListener = TcpListener::bind("localhost:3030").await.unwrap();
        axum::serve(listener, app_router).await.unwrap();
    }

    #[cfg(feature = "lambda")]
    {
        use lambda_http::lambda_runtime::layers::{
            OpenTelemetryFaasTrigger, OpenTelemetryLayer as OTelLayer,
        };
        let runtime =
            lambda_http::lambda_runtime::Runtime::new(lambda_http::Adapter::from(app_router))
                .layer(
                    OTelLayer::new(|| {
                        tracing::info!("OpenTelemetry provider flush on lambda shutdown");
                        tracer_provider.force_flush().unwrap();
                        logger_provider.force_flush().unwrap();
                    })
                    .with_trigger(OpenTelemetryFaasTrigger::Http),
                );
        runtime.run().await.unwrap();
    }

    Ok(())
}

特筆すべき点は、lambda 関数で実行する時のエントリポイントの修正です。

前回の記事では、lambda_http::run関数を利用していましたが、今回は、lambda_http::lambda_runtime::Runtimeを生成して、レイヤーを追加できるようにしています。これによって、プロバイダが lambda 関数の終了時にバッファリングされたテレメトリを必ず、flush するような処理をコールバックとして追加しています。
この処理により、アプリケーション内に送信がされないままテレメトリを残して、lambda 関数が終了することを回避することができます。

OpenTelemetry Lambda Layer


(再掲)Rust & Lambda & OpenTelemetry のコンポーネント関連図

前節で、アプリケーション内の OpenTelemtry の実装は完了しましたので、OpenTelemetry Collector の導入を行います。

Collector を利用する理由

OpenTelemetry Collector は複数のプロトコルや形式でテレメトリデータを受け取り、受け取ったデータの変換を行って、オブザーバビリティバックエンドへのテレメトリを転送する責務をもつコンポーネントです。

OpenTelemetry Collector を利用することで、オブザーバビリティバックエンドのベンダー依存をアプリケーションコード側に持ち込まないようにできる利点があります。

ベンダー依存をもちこまないことが、今回のケースで具体的にどのような利点があるのかを説明します。アプリケーション上の実装で先述した通り、Expoter はアプリケーションコード内でも直接利用できるため、Collector を経由せずに直接、OTLP エンドポイントへのテレメトリ転送を行うことは可能です。具体的には下記のように実装可能です。

otlp-sigv4-client を利用してアプリケーションから直接テレメトリ転送する実装

otlp-sigv4-clientクレートを利用することで、アプリケーション上から直接、OTLP エンドポイントへのテレメトリ転送も可能です。

https://github.com/dev7a/serverless-otlp-forwarder/tree/main/packages/rust/otlp-sigv4-client

let profile = std::env::var("AWS_PROFILE").ok();
    let config = match profile {
            Some(p) => aws_config::from_env().profile_name(p).load().await,
            None => aws_config::load_from_env().await,
        };

    use aws_credential_types::provider::ProvideCredentials;
    let credentials = config
        .credentials_provider()
        .expect("No credentials provider found")
        .provide_credentials()
        .await?;

    let http_client = std::thread::spawn(move || {
        reqwest::blocking::Client::builder()
            .build()
            .unwrap_or_else(|_| reqwest::blocking::Client::new())
    })
    .join()
    .unwrap();

    use otlp_sigv4_client::SigV4ClientBuilder;
    let sigv4_client = SigV4ClientBuilder::new()
        .with_client(http_client.clone())
        .with_credentials(credentials.clone())
        .with_region(
            config
                .region()
                .map(|r| r.to_string())
                .unwrap_or_else(|| "ap-northeast-1".to_string()),
        )
        .with_service("xray")
        .with_signing_predicate(Box::new(|request| {
            // Only sign requests to AWS endpoints
            request.uri().host().is_some_and(|host| {
                // Sign requests to AWS endpoints (*.amazonaws.com)
                // You might want to be more specific based on your needs
                host.ends_with(".amazonaws.com")
            })
        }))
        .build()?;

    use opentelemetry_otlp::WithExportConfig;
    use opentelemetry_otlp::WithHttpConfig;
    let span_exporter = opentelemetry_otlp::SpanExporter::builder()
        .with_http()
        .with_http_client(sigv4_client)
        .with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
        .with_endpoint("https://xray.ap-northeast-1.amazonaws.com/v1/traces")
        .build()
        .expect("Failed to create trace exporter");

しかし、OTLP エンドポイントを利用する時は、sigv4auth の認証処理が必要になるため、AWS の認証関連のクレートや sigv4-client などの追加のクレートの導入と実装をアプリケーションコード側に持ち込まなければならないという問題が発生してしまいます。また、Lambda のライフサイクルに合わせて、テレメトリを転送するように最適化しないと、Lambda の実行時間が増加してしまう可能性もあります。Collector を利用すればこのようなベンダー特有の処理を独立したコンポーネントに委譲することができるため、アプリケーションをシンプルに保つことができます。

Pulumi で OpenTelemetry Lambda Layer の追加

通常の OpenTelemetry Collector は otelcol として実行バイナリ形式で配布され Docker Image にサイドカーとして含められて利用されますが、Lambda では直接利用することができません。Lambda 環境では、Lambda のライフサイクルに最適化された、OpenTelemetry Lambda Layer を利用するのが適切です。

lambda.tsと同じディレクトリ内に、OpenTelemetry Lambda Layer(Colletor) の設定を変更するcollector-config.yamlを追加します。

collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    send_batch_size: 1
    timeout: 200ms
  decouple:
    max_queue_size: 200

exporters:
  otlphttp/logs:
    compression: gzip
    logs_endpoint: https://logs.ap-northeast-1.amazonaws.com/v1/logs
    headers:
      x-aws-log-group: /aws/lambda/dev-otlp-endpoint-lambda-rust-api-lambda
      x-aws-log-stream: default
    auth:
      authenticator: sigv4auth/logs

  otlphttp/traces:
    compression: gzip
    traces_endpoint: https://xray.ap-northeast-1.amazonaws.com/v1/traces
    auth:
      authenticator: sigv4auth/traces

extensions:
  sigv4auth/logs:
    region: ap-northeast-1
    service: logs
  sigv4auth/traces:
    region: ap-northeast-1
    service: xray

service:
  extensions: [sigv4auth/logs, sigv4auth/traces]
  telemetry:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch, decouple]
      exporters: [otlphttp/logs]
    traces:
      receivers: [otlp]
      processors: [batch, decouple]
      exporters: [otlphttp/traces]

設定内容については、下記を参考にしています。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPSimplesetup.html#CloudWatch-OTLPSimplesetupConfigureCollector

設定内容について、特筆すべき点は、decouple プロセッサです。Lambda の実行環境に適したプロセッサであり、Cybozu の記事で従来の ADOT Collector と比較してパフォーマンスが改善したことが報告されています。

ADOT Collector でのパフォーマンス劣化の原因は、 Lambda 関数で利用する場合の特有の問題で、バックエンドへのスパンの送信をアプリケーションの処理と同期して行わなければならなかったことが起因でした。AIServiceFunction の各種エンドポイントにおいては 200ms 以上のレイテンシーのオーバヘッドがありました。

OpenTelemetry Collector AWS Lambda Extension Layer には decouple プロセッサというものが内包されており、完全に非同期でスパンの送信を行ってくれるため、レイテンシーにかかるオーバヘッドが 150ms 程度改善しました。

ただし、decouple プロセッサは完全に非同期でのスパンの送信になるため、Lambda 関数が動作していない間にはスパンが送信されなくなるというトレードオフがあることに注意してください。

https://blog.cybozu.io/entry/2025/09/11/120000

https://opentelemetry.io/blog/2025/observing-lambdas/

Lambda リソースは下記のように変更します。

lambda.ts
 import * as pulumi from "@pulumi/pulumi";
 import * as aws from "@pulumi/aws";
 import * as fs from "node:fs";
 import { local } from "@pulumi/command";
 
 // util
 const NAME_PREFIX: string = `${pulumi.getStack()}-${pulumi.getProject()}`;
 
 export const apiLambdaId: string = `${NAME_PREFIX}-api-lambda`;
 
 const apiLambdaRoleId = `${apiLambdaId}-role`;
 const apiLambdaRole = new aws.iam.Role(apiLambdaRoleId, {
   assumeRolePolicy: JSON.stringify({
     Version: "2012-10-17",
     Statement: [
       {
         Action: "sts:AssumeRole",
         Effect: "Allow",
         Principal: {
           Service: "lambda.amazonaws.com",
         },
       },
     ],
   }),
   managedPolicyArns: [],
   name: apiLambdaRoleId,
   tags: {
     Name: apiLambdaRoleId,
     Project: pulumi.getProject(),
     Stack: pulumi.getStack(),
     Environment: pulumi.getStack(),
     ManagedBy: "pulumi",
   },
 });
 
 const lambdaBasicExecutionPolicyAttachment = new aws.iam.RolePolicyAttachment(
   `${apiLambdaId}-basic-execution-policy-attachment`,
   {
     role: apiLambdaRole.name,
     policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
   },
 );
 
+const lambdaXrayMonitoringPolicy = new aws.iam.Policy(
+  `${apiLambdaId}-xray-monitoring-policy`,
+  {
+    description: "Policy for Lambda to access X-Ray",
+    policy: JSON.stringify({
+        Version: "2012-10-17",
+        Statement: [
+          {
+            Action: [
+              "xray:PutTraceSegments",
+              "xray:PutSpans",
+              "xray:PutSpansForIndexing"
+            ],
+            Effect: "Allow",
+            Resource: [
+              "*"
+            ],
+          },
+        ],
+      }),
+  },
+);
 
+const lambdaXrayMonitoringPolicyAttachment = new aws.iam.RolePolicyAttachment(
+  `${apiLambdaId}-xray-monitoring-policy-attachment`,
+  {
+    role: apiLambdaRole.name,
+    policyArn: lambdaXrayMonitoringPolicy.arn,
+  },
+);
 
 const API_DIR = "api";
 const BIN_PATH = `./${API_DIR}/bin`;
 
 const BUILD_COMMAND = `
 cargo zigbuild --release --target aarch64-unknown-linux-musl --features lambda || exit 1
 mkdir -p bin || exit 1
 cp ./target/aarch64-unknown-linux-musl/release/api ./bin/bootstrap || exit 1
+cp ./aws/collector-config.yaml ./bin/ || exit 1
 `;
 
 const apiBuildCommand = new local.Command(`${apiLambdaId}-build`, {
   create: BUILD_COMMAND,
   dir: `./${API_DIR}`,
   triggers: [
     new pulumi.asset.FileArchive(`./${API_DIR}/src`),
     new pulumi.asset.FileAsset(`./${API_DIR}/Cargo.toml`),
+    new pulumi.asset.FileAsset(`./${API_DIR}/build.rs`),
+    new pulumi.asset.FileAsset(`./${API_DIR}/aws/collector-config.yaml`),
   ],
   environment: {
     PULUMI_STACK: pulumi.getStack(),
+    API_LAMBDA_ARN: selfStack.getOutput("API_LAMBDA_ARN"),
+    PROJECT_NAME: pulumi.getProject(),
   }
 });
 
 export const apiLambda = new aws.lambda.Function(apiLambdaId, {
   architectures: ["arm64"],
   environment: {
     variables: {
       TZ: "Asia/Tokyo",
+      OPENTELEMETRY_COLLECTOR_CONFIG_URI: "/var/task/collector-config.yaml",
+      RUST_LOG: "info",
     },
   },
   code: fs.existsSync(BIN_PATH) ? apiBuildCommand.stdout.apply((_) => {
     return new pulumi.asset.FileArchive(BIN_PATH);
   }) : local.runOutput({
     command: BUILD_COMMAND,
     dir: `./${API_DIR}`,
     environment: {
       PULUMI_STACK: pulumi.getStack(),
+      API_LAMBDA_ARN: selfStack.getOutput("API_LAMBDA_ARN"),
+      PROJECT_NAME: pulumi.getProject(),
     }
   }).apply(_ => {
     return new pulumi.asset.FileArchive(BIN_PATH);
   }),
   ephemeralStorage: {
     size: 512,
   },
   memorySize: 256,
   handler: "bootstrap",
+  layers: [
+    "arn:aws:lambda:ap-northeast-1:184161586896:layer:opentelemetry-collector-arm64-0_18_0:1",
+  ],
   loggingConfig: {
     applicationLogLevel: "INFO",
     logFormat: "JSON",
     logGroup: `/aws/lambda/${apiLambdaId}`,
     systemLogLevel: "WARN",
   },
   name: apiLambdaId,
   packageType: "Zip",
   role: apiLambdaRole.arn,
   runtime: aws.lambda.Runtime.CustomAL2023,
   timeout: 10,
   tags: {
     Name: apiLambdaId,
     Project: pulumi.getProject(),
     Stack: pulumi.getStack(),
     Environment: pulumi.getStack(),
     ManagedBy: "pulumi",
   },
 });
 
 export const apiLambdaUrl = new aws.lambda.FunctionUrl(`${apiLambdaId}-url`, {
   authorizationType: "NONE", // AWS_IAM
   functionName: apiLambda.name,
   invokeMode: "BUFFERED",
 });
 
 export const API_LAMBDA_FUNCTION_URL = apiLambdaUrl.functionUrl.apply((url: string) => {
   // NOTE: url の末尾の / を消す
   return url.replace(/\/$/, '');
 });
 
 export const API_LAMBDA_ROLE_ARN = pulumi.interpolate`${apiLambdaRole.arn}`;
+export const API_LAMBDA_ARN = pulumi.interpolate`${apiLambda.arn}`;
 

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLP-UsingADOT.html#setup-iam-permissions-role

Pulumi による Transaction Search の有効化

OTLP エンドポイントの有効化のために Transaction Search の有効化が必要です。
下記ページの CloudFormation を Pulumi に書き直したものが下記のコードです。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-Transaction-Search-Cloudformation.html

monitoring/aws/transaction-search.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as native from "@pulumi/aws-native";

// util
const NAME_PREFIX: string = `${pulumi.getStack()}-${pulumi.getProject()}`;
const AWS_ACCOUNT_ID = pulumi.output(aws.getCallerIdentity().then(result => result.accountId));
const AWS_REGION = pulumi.output(aws.getRegion().then(result => result.region));

const transactionSearchAccessPolicy = new aws.cloudwatch.LogResourcePolicy(
  `${NAME_PREFIX}-transaction-search-access-policy`,
  {
    policyName: `${NAME_PREFIX}-transaction-search-access-policy`,
    policyDocument: pulumi.all([AWS_ACCOUNT_ID, AWS_REGION]).apply(([accountId, region]) =>
      JSON.stringify({
        Version: "2012-10-17",
        Statement: [
          {
            Action: [
              "logs:PutLogEvents"
            ],
            Principal: {
              Service: "xray.amazonaws.com"
            },
            Effect: "Allow",
            Resource: [
              `arn:aws:logs:${region}:${accountId}:log-group:aws/spans:*`,
              `arn:aws:logs:${region}:${accountId}:log-group:/aws/application-signals/data:*`
            ],
            Condition: {
              ArnLike: {
                "aws:SourceArn": `arn:aws:xray:${region}:${accountId}:*`
              },
              StringEquals: {
                "aws:SourceAccount": accountId
              }
            }
          }
        ]
      })
    )
  }
);

const transactionSearchConfig = new native.xray.TransactionSearchConfig(
  `${NAME_PREFIX}-transaction-search-config`,
  {
    indexingPercentage: 100
  },
  {
    dependsOn: [transactionSearchAccessPolicy],
  }
);

Transaction Search の indexingPercentageは 100% としており、今回の検証では少数の API リクエストしか飛ばさないため、Transaction Search でトレースが確認できない事態を回避するためにそのような設定としています。

index.ts の修正とデプロイ

Lambda と Transaction Search リソースの Pulumi コードの修正が完了したので、index.tsを忘れずに修正します。

index.ts
 import "./api/aws/lambda.ts";
 
 export {
   API_LAMBDA_FUNCTION_URL,
   API_LAMBDA_ROLE_ARN,
+  API_LAMBDA_ARN,
 } from "./api/aws/lambda.ts";
 
+import "./monitoring/aws/transaction-search.ts";

index.tsの修正が完了したら、pulumi up でデプロイします。

CloudWatch でモニタリング

Pulumi でのデプロイが完了したら、/api/v0/hello/api/v0/greetの API を Scalar から叩きます。
しばらく(2,3 分程)待つと、CloudWatch の Application Signals (APM) > トランザクション検索 からトレースを検索できるようになります。トレースのログ一覧については、CloudWatch logs の aws/spansロググループに出力されています。

以上で、OTLP エンドポイントで、トレースを確認することができるようになりました。

2つの Lambda で分散トレーシング


(再掲)OTLP 検証のための AWS サーバレス構成図

ここまでで、単一の Lambda でのトレースをモニタリングしましたが、OpenTelemetry の真価は、複数のサービス間で追跡可能な分散トレーシングです。

本記事の最後のトピックとして、Lambda リソースをクローンして、もう一方の Lambda に API リクエストを飛ばすエンドポイントを作成し、分散トレーシングの検証を行います。

Propagator

Propagator は、サービスやプロセス間でトレースID や スパンID を伝送するための仕組みを責務とします。
OpenTelmetry では W3C Trace Context という仕様をサポートし、HTTP 通信ではヘッダーを利用して伝送します。

https://opentelemetry.io/docs/concepts/context-propagation/

W3C Trace Context は下記の記事で解説されています。

https://qiita.com/sukatsu1222/items/82819461921deba761b9

Propagator は Extractor を通して送られてきた、オブジェクト(例えば、HTTP ヘッダーオブジェクト)を取り出し、トレースID や スパンID を含む Context オブジェクトに変換を行います。一方で、他のサービスに伝送する時は、コンテキスト(スパン)を Injector を通して HTTP ヘッダーに書き込みます。
このように、Propagator は伝送するフォーマット(W3C Trace Context)とコンテキストを、Extractor と Injector を介して相互に変換する役割をもちます。

(これは私見なのですが)一見、Extractor や Injector がなくとも、Propagator 1つで役割を果たせそうですが、実装上 HTTP のヘッダーのデータ型などは OpenTelemetry の外部クレートで通常は定義されているため、Propagator にそれらの外部クレートの依存をもち込ませないために、分離された設計になっていそうです。

Rust における W3C Trace Contex 形式の Propagator の実装としては、TraceContextPropagatoropentelemetry_sdkクレートにより提供されており、下記のようにして、アプリケーションでグローバルに利用する Propagator を登録します。

main.rs
 #[tokio::main]
 async fn main() -> anyhow::Result<()> {
+    opentelemetry::global::set_text_map_propagator(
+        opentelemetry_sdk::propagation::TraceContextPropagator::new(),
+    );
 ...

Extractor

分散トレーシングの実現のため、まずは他のサービスから API リクエストが来た時に、コンテキスト(トレースID や スパンID などのデータ)を取り出し、その情報を親スパンとして現在のスパンに登録する処理を作成します。

tower-httpクレートのTraceLayerを利用した実装として下記を参考にしました。

https://zenn.dev/pyama2000/articles/0ad0e8db11d854#ミドルウェアでトレースを生成する

HTTP サーバーにおけるスパンに埋め込む必要がある属性は下記の OpenTelemetry Semantic conventions のページを参照しました。

https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server

otel.rs

pub fn make_span_with_impl(req: &axum::extract::Request<axum::body::Body>) -> tracing::Span {
    use opentelemetry_http::HeaderExtractor;
    use tracing_opentelemetry::OpenTelemetrySpanExt;
    let empty = tracing::field::Empty;
    let method: &str = req.method().as_str();
    let route: &str = req
        .extensions()
        .get::<axum::extract::MatchedPath>()
        .map_or_else(|| "", |p| p.as_str());
    let span_name: String = format!("{} {}", method, route).trim().to_string();
    let span: tracing::Span = tracing::info_span!(
        "",
        otel.name = span_name,
        { opentelemetry_semantic_conventions::trace::URL_PATH } = empty,
        { opentelemetry_semantic_conventions::trace::URL_SCHEME } = empty,
        { opentelemetry_semantic_conventions::trace::HTTP_ROUTE } = empty,
        { opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD } = empty,
        { opentelemetry_semantic_conventions::trace::HTTP_REQUEST_HEADER } = empty,
        { opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION } = empty,
        { opentelemetry_semantic_conventions::trace::CLIENT_ADDRESS } = empty,
        { opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL } = empty,
        { opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE } = empty,
        { opentelemetry_semantic_conventions::attribute::ERROR_TYPE } = empty,
    );
    span.set_parent(opentelemetry::global::get_text_map_propagator(
        |propagator| propagator.extract(&HeaderExtractor(req.headers())),
    ))
    .expect("Failed to set parent span from request headers");
    span
}

pub fn on_request_impl(req: &axum::extract::Request<axum::body::Body>, span: &tracing::Span) {
    span.record(
        opentelemetry_semantic_conventions::trace::URL_PATH,
        req.uri().path(),
    );
    span.record(
        opentelemetry_semantic_conventions::trace::URL_SCHEME,
        req.uri().scheme_str().unwrap_or("http"),
    );
    span.record(
        opentelemetry_semantic_conventions::trace::HTTP_ROUTE,
        req.extensions()
            .get::<axum::extract::MatchedPath>()
            .map_or_else(|| "", |p| p.as_str()),
    );
    span.record(
        opentelemetry_semantic_conventions::trace::HTTP_REQUEST_METHOD,
        req.method().as_str(),
    );
    span.record(
        opentelemetry_semantic_conventions::trace::HTTP_REQUEST_HEADER,
        tracing::field::debug(req.headers()),
    );
    span.record(
        opentelemetry_semantic_conventions::trace::NETWORK_PROTOCOL_VERSION,
        tracing::field::debug(req.version()),
    );
    span.record(
        opentelemetry_semantic_conventions::trace::CLIENT_ADDRESS,
        req.headers()
            .get(axum::http::header::HOST)
            .map(|v| v.to_str().unwrap_or_default()),
    );
    span.record(
        opentelemetry_semantic_conventions::trace::USER_AGENT_ORIGINAL,
        req.headers()
            .get(axum::http::header::USER_AGENT)
            .map(|v| v.to_str().unwrap_or_default()),
    );
}

pub fn on_response_impl(
    res: &axum::response::Response,
    _: std::time::Duration,
    span: &tracing::Span,
) {
    let status = res.status();
    span.record(
        opentelemetry_semantic_conventions::trace::HTTP_RESPONSE_STATUS_CODE,
        tracing::field::display(status),
    );
    if !status.is_success() {
        span.record(
            opentelemetry_semantic_conventions::trace::ERROR_TYPE,
            tracing::field::display(status),
        );
    }
}

opentelemetry-httpクレートで提供されるHeaderExtractorを Propagator は利用し、http::header::HeaderMap型からコンテキストを取り出しています。

axum router の tower レイヤーに TraceLayerを追加します。イベントには先ほど作成した実装をコールバックとして与えます。

main.rs
 use utoipa_scalar::{Scalar, Servable};
 let app_router = axum::Router::new()
     .merge(api_router)
     .merge(Scalar::with_url(format!("{}/docs", API_BASE_PATH), api_docs))
     .layer(tower_http::cors::CorsLayer::permissive())
+    .layer(
+        tower_http::trace::TraceLayer::new_for_http()
+            .make_span_with(otel::make_span_with_impl)
+            .on_request(otel::on_request_impl)
+            .on_response(otel::on_response_impl)
+    );

Injector

Injector は他のサービスにリクエストを投げるときに必要となります。
このため、hello.rs/api/v0/helloをもう一方の Lambda を介して呼び出すための/api/v0/hello/remoteエンドポイントを作成して、API リクエストを投げられるようにします。

hello.rs
#[utoipa::path(
    get,
    path = "/hello/remote",
    responses(
        (status = 200, body = String),
    ),
    tags = [ HELLO_TAG ]
)]
async fn hello_remote() -> String {
    use opentelemetry_http::HeaderInjector;
    let span = tracing::info_span!("hello remote");
    let remote_endpoint: String = env!("REMOTE_ENDPOINT").to_string();
    let url = format!("{}/api/v0/hello", remote_endpoint);
    let mut headers = reqwest::header::HeaderMap::new();
    opentelemetry::global::get_text_map_propagator(|propagator| {
        propagator.inject_context(&span.context(), &mut HeaderInjector(&mut headers))
    });
    let client = reqwest::Client::new();
    let response = client.get(&url)
        .headers(headers)
        .send()
        .await;
    //reqwest::get(&url).await;
    match response {
        Ok(res) => {
            let body = res.text().await.unwrap();
            tracing::info!("Received response from remote: {}", body);
            body
        }
        Err(err) => {
            tracing::error!("Failed to call remote endpoint: {}", err);
            format!("Error calling remote endpoint: {}", err)
        }
    }
}

hello.rsの Axum ルーターを更新します。

hello.rs
 use utoipa_axum::router::OpenApiRouter;
 pub fn create_hello_router() -> OpenApiRouter {
     let hello_router: OpenApiRouter = OpenApiRouter::new()
         .routes(utoipa_axum::routes!(hello))
         .routes(utoipa_axum::routes!(greet));
+        .routes(utoipa_axum::routes!(hello_remote))
     hello_router
 }

Extractor の時と同様にopentelemetry-httpクレートで提供されるHeaderInjectorとコンテキストを Propagator は利用し、http::header::HeaderMapにコンテキストの情報を書き込んでいます。その後で、HTTP クライアントを提供するreqwestクレートを利用し、もう一方の lambda に/api/v0/helloを呼び出すようにリクエストを投げています。

もう一つの Lambda リソースの追加と環境変数の修正

アプリケーション側の実装は完了したので、Lambda の追加と環境変数の修正を行います。

別の Lambda のエンドポイントを設定するREMOTE_ENDPOINT環境変数は、別の Lambda リソースの Lambda Function URL を利用するために、StackReferenceを利用して参照します。

lambda.ts
...
+const selfStack = new pulumi.StackReference(
+  `organization/${pulumi.getProject()}/${pulumi.getStack()}`,
+);
...
  environment: {
    PULUMI_STACK: pulumi.getStack(),
    API_LAMBDA_ARN: selfStack.getOutput("API_LAMBDA_ARN"),
    PROJECT_NAME: pulumi.getProject(),
+   REMOTE_ENDPOINT: selfStack.getOutput("API_LAMBDA_REMOTE_FUNCTION_URL"),
  }

既存の Lambda リソースを参考に Lambda のリソースを作成します。リソース名は、api-lambda-remoteとします。

lambda-remote.ts
lambda-remote.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as fs from "node:fs";
import { local } from "@pulumi/command";

// util
const NAME_PREFIX: string = `${pulumi.getStack()}-${pulumi.getProject()}`;

export const apiLambdaRemoteId: string = `${NAME_PREFIX}-api-lambda-remote`;

const apiLambdaRemoteRoleId = `${apiLambdaRemoteId}-role`;
const apiLambdaRemoteRole = new aws.iam.Role(apiLambdaRemoteRoleId, {
  assumeRolePolicy: JSON.stringify({
    Version: "2012-10-17",
    Statement: [
      {
        Action: "sts:AssumeRole",
        Effect: "Allow",
        Principal: {
          Service: "lambda.amazonaws.com",
        },
      },
    ],
  }),
  managedPolicyArns: [],
  name: apiLambdaRemoteRoleId,
  tags: {
    Name: apiLambdaRemoteRoleId,
    Project: pulumi.getProject(),
    Stack: pulumi.getStack(),
    Environment: pulumi.getStack(),
    ManagedBy: "pulumi",
  },
});

const lambdaBasicExecutionPolicyAttachment = new aws.iam.RolePolicyAttachment(
  `${apiLambdaRemoteId}-basic-execution-policy-attachment`,
  {
    role: apiLambdaRemoteRole.name,
    policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
  },
);

const lambdaXrayMonitoringPolicy = new aws.iam.Policy(
  `${apiLambdaRemoteId}-xray-monitoring-policy`,
  {
    description: "Policy for Lambda to access X-Ray",
    policy: JSON.stringify({
        Version: "2012-10-17",
        Statement: [
          {
            Action: [
              "xray:PutTraceSegments",
              "xray:PutSpans",
              "xray:PutSpansForIndexing"
            ],
            Effect: "Allow",
            Resource: [
              "*"
            ],
          },
        ],
      }),
  },
);

const lambdaXrayMonitoringPolicyAttachment = new aws.iam.RolePolicyAttachment(
  `${apiLambdaRemoteId}-xray-monitoring-policy-attachment`,
  {
    role: apiLambdaRemoteRole.name,
    policyArn: lambdaXrayMonitoringPolicy.arn,
  },
);

const API_DIR = "api";
const BIN_PATH = `./${API_DIR}/bin`;

const BUILD_COMMAND = `
cargo zigbuild --release --target aarch64-unknown-linux-musl --features lambda || exit 1
mkdir -p bin || exit 1
cp ./target/aarch64-unknown-linux-musl/release/api ./bin/bootstrap || exit 1
cp ./aws/collector-config.yaml ./bin/ || exit 1
`;

const apiBuildCommand = new local.Command(`${apiLambdaRemoteId}-build`, {
  create: BUILD_COMMAND,
  dir: `./${API_DIR}`,
  triggers: [
    new pulumi.asset.FileArchive(`./${API_DIR}/src`),
    new pulumi.asset.FileAsset(`./${API_DIR}/Cargo.toml`),
    new pulumi.asset.FileAsset(`./${API_DIR}/build.rs`),
    new pulumi.asset.FileAsset(`./${API_DIR}/aws/collector-config.yaml`),
  ],
  environment: {
    PULUMI_STACK: pulumi.getStack(),
  }
});

export const apiLambdaRemote = new aws.lambda.Function(apiLambdaRemoteId, {
  architectures: ["arm64"],
  environment: {
    variables: {
      TZ: "Asia/Tokyo",
      OPENTELEMETRY_COLLECTOR_CONFIG_URI: "/var/task/collector-config.yaml",
      RUST_LOG: "info",
    },
  },
  code: fs.existsSync(BIN_PATH) ? apiBuildCommand.stdout.apply((_) => {
    return new pulumi.asset.FileArchive(BIN_PATH);
  }) : local.runOutput({
    command: BUILD_COMMAND,
    dir: `./${API_DIR}`,
    environment: {
      PULUMI_STACK: pulumi.getStack(),
    }
  }).apply(_ => {
    return new pulumi.asset.FileArchive(BIN_PATH);
  }),
  ephemeralStorage: {
    size: 512,
  },
  memorySize: 256,
  handler: "bootstrap",
  layers: [
    "arn:aws:lambda:ap-northeast-1:184161586896:layer:opentelemetry-collector-arm64-0_18_0:1",
  ],
  loggingConfig: {
    applicationLogLevel: "INFO",
    logFormat: "JSON",
    logGroup: `/aws/lambda/${apiLambdaRemoteId}`,
    systemLogLevel: "WARN",
  },
  name: apiLambdaRemoteId,
  packageType: "Zip",
  role: apiLambdaRemoteRole.arn,
  runtime: aws.lambda.Runtime.CustomAL2023,
  timeout: 10,
  tags: {
    Name: apiLambdaRemoteId,
    Project: pulumi.getProject(),
    Stack: pulumi.getStack(),
    Environment: pulumi.getStack(),
    ManagedBy: "pulumi",
  },
});

export const apiLambdaRemoteUrl = new aws.lambda.FunctionUrl(`${apiLambdaRemoteId}-url`, {
  authorizationType: "NONE", // AWS_IAM
  functionName: apiLambdaRemote.name,
  invokeMode: "BUFFERED",
});

export const API_LAMBDA_REMOTE_FUNCTION_URL = apiLambdaRemoteUrl.functionUrl.apply((url: string) => {
  // NOTE: url の末尾の / を消す
  return url.replace(/\/$/, '');
});

export const API_LAMBDA_REMOTE_ROLE_ARN = pulumi.interpolate`${apiLambdaRemoteRole.arn}`;

最後に index.ts を修正します。

index.ts
 import "./api/aws/lambda.ts";
 
 export {
   API_LAMBDA_FUNCTION_URL,
   API_LAMBDA_ROLE_ARN,
   API_LAMBDA_ARN,
 } from "./api/aws/lambda.ts";
 
 import "./api/aws/lambda-remote.ts";
 
+export {
+  API_LAMBDA_REMOTE_FUNCTION_URL,
+  API_LAMBDA_REMOTE_ROLE_ARN,
+} from "./api/aws/lambda-remote.ts";
 
 import "./monitoring/aws/transaction-search.ts";
 

pulumi upでデプロイします。

CloudWatch で分散トレースのモニタリング

既存の lambda に対して、/api/v0/hello/remoteを Scalar から叩きます。

しばらくすると Transaction Search でトレースを検索すると、サービス間を跨いだスパンが出力されていることが確認できます。(GET /api/v0/hello/remoteは 既存の Lambda, GET /api/v0/helloは新しく追加した Lambda のスパン)

トレースの詳細について確認すると、メタデータでサービス間を跨いだスパンが1つのトレースとしてモニタリングできていることが確認できました。

トレースマップについては、歯車のアイコンが2つ表示されることを期待しましたが、Application Signal を利用していないためか、検出できないようです。こちらについては今後の課題です。

GET /api/v0/hello/remote のメタデータ
{
  "thread.name": "main",
  "url.scheme": "https",
  "telemetry.sdk.name": "opentelemetry",
  "client.address": "moyf7u7o6eshgygts3adrw7txu0majyw.lambda-url.ap-northeast-1.on.aws",
  "vcs.repository.url.full": "",
  "telemetry.sdk.language": "rust",
  "code.module.name": "api::otel",
  "http.request.method": "GET",
  "service.instance.id": "da164663-e60d-40ad-a53d-1aa1fd6a2d61",
  "vcs.ref.head.name": "master",
  "cloud.resource_id": "",
  "telemetry.sdk.version": "0.31.0",
  "busy_ns": 61649353,
  "cloud.platform": "aws_lambda",
  "code.line.number": 189,
  "thread.id": 1,
  "user_agent.original": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
  "deployment.environment.name": "dev",
  "vcs.repository.name": "",
  "idle_ns": 825170474,
  "service.name": "api",
  "cloud.region": "ap-northeast-1",
  "network.protocol.version": "HTTP/1.1",
  "faas.name": "dev-otlp-endpoint-lambda-rust-api-lambda",
  "code.file.path": "src/otel.rs",
  "faas.max_memory": 268435456,
  "service.namespace": "otlp-endpoint-lambda-rust",
  "target": "api::otel",
  "cloud.provider": "aws",
  "url.path": "/api/v0/hello/remote",
  "http.request.header": "{\"sec-fetch-mode\": \"cors\", \"referer\": \"https://moyf7u7o6eshgygts3adrw7txu0majyw.lambda-url.ap-northeast-1.on.aws/api/docs\", \"x-amzn-tls-version\": \"TLSv1.3\", \"sec-fetch-site\": \"same-origin\", \"x-forwarded-proto\": \"https\", \"accept-language\": \"ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7\", \"x-forwarded-port\": \"443\", \"x-forwarded-for\": \"2400:4052:1a2:5500:9d04:a7a3:ea73:13cb\", \"accept\": \"*/*\", \"x-amzn-tls-cipher-suite\": \"TLS_AES_128_GCM_SHA256\", \"sec-ch-ua\": \"\\\"Google Chrome\\\";v=\\\"141\\\", \\\"Not?A_Brand\\\";v=\\\"8\\\", \\\"Chromium\\\";v=\\\"141\\\"\", \"x-amzn-trace-id\": \"Root=1-69011bed-2c15b3ad6846baad1dd02858;Parent=08ad196eb74b3482;Sampled=0;Lineage=1:d71f1e85:0\", \"sec-ch-ua-mobile\": \"?0\", \"sec-ch-ua-platform\": \"\\\"macOS\\\"\", \"host\": \"moyf7u7o6eshgygts3adrw7txu0majyw.lambda-url.ap-northeast-1.on.aws\", \"accept-encoding\": \"gzip, deflate, br, zstd\", \"user-agent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36\", \"sec-fetch-dest\": \"empty\"}",
  "service.version": "0.1.0",
  "http.route": "/api/v0/hello/remote",
  "vcs.ref.type": "branch",
  "faas.instance": "2025/10/28/dev-otlp-endpoint-lambda-rust-api-lambda[$LATEST]ae5e2af2fc4a45b3b6c6a1fd8bf4d770",
  "faas.version": "$LATEST",
  "PlatformType": "AWS::Lambda",
  "vcs.ref.head.revision": "d7df1000f951221f2a9834bf97c7439516b2e92c"
}
GET /api/v0/hello のメタデータ
{
  "thread.name": "main",
  "url.scheme": "https",
  "telemetry.sdk.name": "opentelemetry",
  "client.address": "2caxraezqtqlr67o3qbfah4qpe0gjgef.lambda-url.ap-northeast-1.on.aws",
  "vcs.repository.url.full": "",
  "telemetry.sdk.language": "rust",
  "code.module.name": "api::otel",
  "http.request.method": "GET",
  "service.instance.id": "185f7ca6-238c-40c0-8db5-39f63554635c",
  "vcs.ref.head.name": "master",
  "cloud.resource_id": "",
  "telemetry.sdk.version": "0.31.0",
  "busy_ns": 410741,
  "cloud.platform": "aws_lambda",
  "code.line.number": 189,
  "thread.id": 1,
  "deployment.environment.name": "dev",
  "vcs.repository.name": "",
  "idle_ns": 129140,
  "service.name": "api",
  "cloud.region": "ap-northeast-1",
  "network.protocol.version": "HTTP/1.1",
  "faas.name": "dev-otlp-endpoint-lambda-rust-api-lambda-remote",
  "code.file.path": "src/otel.rs",
  "service.namespace": "",
  "faas.max_memory": 268435456,
  "target": "api::otel",
  "cloud.provider": "aws",
  "url.path": "/api/v0/hello",
  "http.request.header": "{\"x-amzn-tls-cipher-suite\": \"TLS_AES_128_GCM_SHA256\", \"x-amzn-tls-version\": \"TLSv1.3\", \"tracestate\": \"\", \"x-amzn-trace-id\": \"Root=1-69011bee-242d0f9a47b14b2d153524bc;Parent=5423a9871f8e1787;Sampled=0;Lineage=1:b44afd80:0\", \"x-forwarded-proto\": \"https\", \"traceparent\": \"00-5d84142676871faa0038c5cd7f0a0b38-ccb33a2e23b5df89-01\", \"host\": \"2caxraezqtqlr67o3qbfah4qpe0gjgef.lambda-url.ap-northeast-1.on.aws\", \"x-forwarded-port\": \"443\", \"x-forwarded-for\": \"18.180.215.78\", \"accept\": \"*/*\"}",
  "service.version": "0.1.0",
  "http.route": "/api/v0/hello",
  "vcs.ref.type": "branch",
  "faas.instance": "2025/10/28/dev-otlp-endpoint-lambda-rust-api-lambda-remote[$LATEST]ea34dc5d0bd34fa39a02bd2ed344626d",
  "faas.version": "$LATEST",
  "PlatformType": "AWS::Lambda",
  "vcs.ref.head.revision": "d7df1000f951221f2a9834bf97c7439516b2e92c"
}

まとめ

Rust を使った Lambdalith な Axum Web サーバーに、OpenTelemetry を使ったトレーシングを実装し、AWS OTLP エンドポイントを利用して CloudWatch でトレース監視をできるようにする方法を試しました。

これらの実装を通して、Rust や他の言語においても OpenTelemetry を自信をもって利用できる自信をもてて頂ける材料となれば幸いです。

今後の課題としては、OpenTelemetry のメトリクス(Expoter で aws emf 形式にすれば X-Ray と容易に連携可能か?)や Application Map によるトポロジーの可視化について利用できるようになれればと思います。

Discussion