スパンを手作りすることで OpenTelemetry のトレースの基礎を理解する

Mackerel では OpenTelemetry の主要なシグナルのうちのトレースに対応した分散トレーシング機能である Vaxila が使えるようになりました。Web アプリケーションのハンドラをラップすることで簡単に計装するライブラリなどは使ったことがあるのですが、今回は自分でスパンを作りトレースを計装することでより理解を深めてみようと思います。

今回は HTTP リクエストが送信されてからレスポンスが返ってくるまでの時間をトレースしてみようと思います。Mackerel の外形監視ではステータスコード、証明書の有効期限、レスポンスボディのチェックやレスポンスタイムの可視化などはできますが、DNS の名前解決、コネクションの確立、TLS ハンドシェイクの時間などを見ることができないためこれらをスパンとして送信してみます。

概念のおさらい

トレースの概念に関しては OpenTelemetry のドキュメントが簡潔にまとまっていてわかりやすいです。 - トレース | OpenTelemetry - Overview | OpenTelemetry

HTTP リクエスト内部の計装

単純に HTTP リクエストを計装するだけなら otelhttp package を使用し、既存の handler や middleware をラップするだけで完結しますが、今回は DNS の名前解決なども計装したいので otelhttp.NewTransport を用いて Transport を差し替えます。Go で DNS の名前解決などの HTTP リクエスト内部のイベントをトレースするには httptrace package が使用できます(用語が混じってややこしいですがこちらは OTel のトレースとは別物)。ClientTrace の定義を見るとわかるように、名前解決の開始と終了、TLS ハンドシェイクの開始と終了など様々なタイミングにフックを仕込むことができます。

trace package と上記のフックを組み合わせて HTTP の内部処理のスパンを作成します。tracer.Start / tracer.End にオプションを渡すことでタイムスタンプや属性など様々な設定ができます。実際にやってみたのが以下のコード。

package main

import (
    "context"
    "crypto/tls"
    "log"
    "net/http"
    "net/http/httptrace"
    "time"

    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/trace"
)

func initTracer() func() {
    exporter, err := otlptracegrpc.New(context.Background())
    if err != nil {
        log.Fatalf("failed to initialize exporter: %v", err)
    }

    tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter))
    otel.SetTracerProvider(tp)

    return func() {
        if err := tp.Shutdown(context.Background()); err != nil {
            log.Fatalf("failed to shut down tracer provider: %v", err)
        }
    }
}

func main() {
    shutdown := initTracer()
    defer shutdown()

    url := "https://example.com"

    client := http.Client{
        Transport: otelhttp.NewTransport(
            http.DefaultTransport,
            otelhttp.WithClientTrace(func(ctx context.Context) *httptrace.ClientTrace {
                tracer := otel.Tracer("example/client")

                var dnsStart, handShakeStart, connStart time.Time
                var host string

                return &httptrace.ClientTrace{
                    DNSStart: func(info httptrace.DNSStartInfo) {
                        dnsStart = time.Now()
                        host = info.Host
                    },
                    DNSDone: func(info httptrace.DNSDoneInfo) {
                        dnsEnd := time.Now()
                        _, span := tracer.Start(ctx, "DNS Lookup",
                            trace.WithTimestamp(dnsStart),
                            trace.WithAttributes(attribute.String("host", host)),
                            trace.WithSpanKind(trace.SpanKindClient),
                        )
                        span.End(trace.WithTimestamp(dnsEnd))
                    },
                    TLSHandshakeStart: func() {
                        handShakeStart = time.Now()
                    },
                    TLSHandshakeDone: func(state tls.ConnectionState, err error) {
                        handShakeEnd := time.Now()
                        _, span := tracer.Start(ctx, "TLS Handshake",
                            trace.WithTimestamp(handShakeStart),
                            trace.WithAttributes(attribute.Int("CipherSuite", int(state.CipherSuite))),
                            trace.WithSpanKind(trace.SpanKindClient),
                        )
                        if err != nil {
                            span.RecordError(err)
                        }
                        span.End(trace.WithTimestamp(handShakeEnd))
                    },
                    ConnectStart: func(network, addr string) {
                        connStart = time.Now()
                    },
                    ConnectDone: func(network, addr string, err error) {
                        connEnd := time.Now()
                        _, span := tracer.Start(ctx, "Connection Establishment",
                            trace.WithTimestamp(connStart),
                            trace.WithAttributes(
                                attribute.String("network", network),
                                attribute.String("address", addr),
                            ),
                            trace.WithSpanKind(trace.SpanKindClient),
                        )
                        if err != nil {
                            span.RecordError(err)
                        }
                        span.End(trace.WithTimestamp(connEnd))
                    },
                }
            }),
        ),
    }

    ctx := context.Background()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

    if err != nil {
        log.Fatalf("failed to create request: %v", err)
    }

    resp, err := client.Do(req)

    if err != nil {
        log.Fatalf("failed to perform request: %v", err)
    }

    defer resp.Body.Close()

    log.Printf("Response status: %s", resp.Status)
}

各フックポイントでタイムスタンプやホスト名、IP アドレスなどを属性として設定しています。また、エラーが起きた場合はスパンのエラーを設定しています。以下の通り HTTP リクエストを親として各内部処理がスパンとして構造化されていることがわかります。

otel-tuiで可視化した様子

otelhttp.WithClientTrace を使う

ある程度コードを書いてからドキュメントの裏どりや世の中で同じことをしている人がいないかを改めて調べてみたところ otelhttptrace というパッケージが用意されていることを知りました。1

このパッケージを使うと http.Clientを作る部分はこれだけで済みます。

   client := http.Client{
        Transport: otelhttp.NewTransport(
            http.DefaultTransport,
            otelhttp.WithClientTrace(func(ctx context.Context) *httptrace.ClientTrace {
                return otelhttptrace.NewClientTrace(ctx)
            }),
        ),
    }

取得する情報も増えていて、より適切に構造化されていますね。

otelhttp.WithClientTrace を使った場合

初めからこれを使えばよかったんや…という気持ちになりましたが、今回は手でスパンを作ってみるということが目的だったのでグッとその気持ちを飲み込みます。せっかくライブラリがあることを見つけたのでそちらではどういったことをしているのかを少し見てみました。

型定義を見ていくと、トレーサープロバイダーを指定したり、収集する情報のカスタマイズができるようです。デフォルトでは認証用のヘッダーが落とされるのでそれを取得するようにしたり、見つかった情報全てがスパンになることを抑制できたりもする。

clienttrace.go をのぞいてみると、フックの開始・終了処理を共通化して各タイミングで設定したい属性と一緒にそれが呼ばれています。自分で書いた実装では WithTimestamp を使って明にタイムスタンプを設定していましたが、context からスパンを取得して開始・終了のタイミングで同じスパンの Start/End を呼んでいるようです。型定義のところで見たuseSpans フラグによってイベントと属性のみ設定するという処理もありました。

Vaxila に送信してみる

最後に Vaxila にトレースを送信してみます。エクスポーターの設定を以下のように変えるだけです。標準化されたプロトコルの便利さを改めて感じますね。

   exporter, err := otlptracehttp.New(context.Background(),
        otlptracehttp.WithEndpointURL("https://otlp-vaxila.mackerelio.com"),
        otlptracehttp.WithHeaders(map[string]string{
            "Mackerel-Api-Key": os.Getenv("MACKEREL_APIKEY"),
        }),
    )

いい感じに表示されました。

Vaxila にトレースを送信した様子

実際に手を動かしてトレースを計装することにより、スパンの親子関係の設定や属性、イベントの概念のおさらいができたのと、スパンの種類や(今回の実装には出てきませんでしたが)リンクなど触れたことのない概念があるということも知ることができました。


この記事は Mackerel Advent Calendar 2024 7日目の記事でした。

qiita.com

昔ながらのボトルに入ったラムネを常備してたのだけど、最近はパックに入ったやつもあると知ったのでこっちを買ってみた。一粒食べてみたけどちょっと食べ応えが違う。ボトルの方は密度が高くて硬い感じがするけどパックの方はさっとすぐ溶ける。

 

CREになって得たよろこび

ウェブアプリケーションエンジニアから Mackerel の CRE になり、1年が経とうとしている。これまではコードを書いたり開発計画を立ててプロジェクトの進行をしたりが主な仕事だったが、ユーザであるお客さんと直接対話する機会が増えてきた。これまでもビジネスチームのメンバーが商談を設定する場に技術的な相談役として同席するということはあったけれど、CRE はそれを超えてユーザに対する理解を深めて課題を見極めるということが使命の一つである。

最近気持ちに変化があることに気がついた。開発者として商談に同席したりユーザと直接話す場では、いつもちょっと緊張しつつ間違ったことを言ってはいけないと考えることの方が多かったけれど、今ではもっとユーザの気持ちを知りたいし共感したい、課題を知りたいという気持ちが強くなっているのである。ユーザの理解を深め課題の輪郭を明確にする材料をうまく集められた時に達成感が生まれる*1 。ユーザと直接対話できることが嬉しいのである。

これまでも、新しく作ったサービスをローンチしたり既存のサービスに新しい機能をつけリリースするたびに、 SNSソーシャルブックマークなどでユーザの反応を見聞きし、喜んだり落ち込んだりしながらサービス開発に活かしていた。だが、今の感情はそれよりも一歩踏み込んだものだなと思う。

最近では、ユーザインタビューの場に開発チームのメンバーに同席してもらい直接対話してもらえる機会を作ったり、インタビュー以外にも、開発者に会いに行けるサービスという標語を掲げオフラインイベントの企画・運営もしている。オフラインイベントは我々やサービスについてユーザに知ってもらう場だけでなくて、直接ユーザとコミュニケーションできる場としても重要である。ユーザにとって「開発者に会いに行ける場」であると同時に我々が「ユーザに会える場」でもあるということである。

私が CRE に転身し新たに感じることができたよろこびを、少しでもチームメンバーにも感じてもらえているとうれしい。


これは はてなエンジニア Advent Calendar 2023 1月3日 の記事です。

ライブ遠征に持っていくと便利!ホテル滞在で必須のお役立ちグッズ100選

※会社の朝会スピーチで話した内容をそのまま公開しています

みなさん遠征してますか?ライブ、観劇、スポーツ観戦などガチ勢が多い弊社ですが、私も遠征をして観光とライブを楽しんでいます*1。先週末は松山に行ってきました。今週末は名古屋に行きます。来週は鹿児島・福岡、再来週は沖縄に行きます。誰か止めてほしい。

 

そんな私が1泊の場合でも必ず持って行く遠征お役立ちグッズをご紹介します。

1. 衣類圧縮袋

hands.net

長めの旅行などの際にスーツケースの整理のために使うという方も多いのではないでしょうか。私はむしろ短い滞在の方が役にたつことが多いと思っています。

行きは下着や靴下を入れていき、帰りはそれに加えてタオルや行きで着ていたTシャツなど小さめなものであればまとめて圧縮して持って帰れます。多少濡れていたとしてもチャックがしっかりしているので安心です。カバン一つの時は荷物はなるべくコンパクトにまとめたいので、一つの袋になんでも放り込めて体積も小さくなるのは最高です。

百均とかでも買えますが、チャックがすぐダメになったり口の部分が裂けたりするのである程度いいやつを買うのがおすすめです。

2. Anker の USB 充電器 + Apple Watch 充電器

現代人にとってガジェット類の充電は常に頭を悩ませる問題ですよね。さらに遠出する時は荷物を少なくしたい。現状の私の最適解はこちらです。

USB Type-C 2口の充電器と Apple Watch の充電器 + Lightningケーブル

最低限 Apple WatchiPhone だけ充電できればよい、AirPods は Lightningケーブル でも Apple Watch の充電器でも充電できるので両対応という感じです。

携帯用のモバイルバッテリーも Anker の 10000mAh のやつを別途持ち歩いていますが、移動中などはケーブルを抜き差しするのが煩わしいなと感じることが増えてきたので MagSafe 対応のやつにリプレースを検討中です。

2024/01/21 追記

充電器をコンパクトなやつに買い替えました。今はこういう感じです。

f:id:KGA:20240121223943j:image

上の写真のやつの半分くらいのサイズになった

 

3. 無印良品 吊るして使えるケース

泊まりで出かける時って普段使っている日用品で絶対持っていきたいものとホテルにあるやつでいいやとなるものがあると思うのですが、前者をひとまとめにしておいていつでも持ち出せるようにできるのがこちら。

フロス、化粧水、うがい薬、リステリン、ホテルからパクってきたアメニティたち

荷造りのたびに「あれ入れたっけ???」と考える必要がないのと、フックがついてるのでホテルに着いたらまず洗面所にこれをセットするだけで滞在の準備がほぼ終わるというのがおすすめポイントです。

私が使っているのは結構前のバージョンで、このエントリを書くために調べたら現行バージョンでは真ん中の部分がポーチとして取り外せるようになってるらしく、買い換えようかという気持ちになっています。ホテルに大浴場とかある時に持って行きやすそう。

みなさんのおすすめグッズも教えてください!

*1:今年に入ってからだと、福岡、大阪、長野、名古屋、大阪