Rushbox Lite のキースイッチを Haute42 SHADOW HUNTING に交換した

格ゲー用のコントローラとして Rushbox Lite というやつを使っている。自作キーボード界隈の会社がレバーレスコントローラのクラウドファンディングを始めるということを知り開始直後に支援してゲットしたもの。程よいサイズ感や軽さ、マットな質感など気に入っていて、 大きな不満はなかったのだけど、もう少しキーストロークが浅ければなと思っていた。

2024年の年末ごろに Haute42 が SHADOW HUNTING という押下圧も軽くアクチュエーションポイントやストロークも短いキースイッチを出したというのを知り、試しに交換してみることにした。最初はアリエクで買おうかと思ったけど日本の Amazon でも大して値段が変わらなかったので Amazon で購入。

キーボードも含め、キースイッチ周りをいじるのは初めてだったけど、Rushbox にキースイッチプラーが付属していて、公式のマニュアルでも動画付きでやり方が解説されているので特に迷わず交換できた。

ボタンのレイアウトを変更する |Rushboxマニュアル

SHADOW HUNTING: 小窓からスイッチが見えててかわいい

引っこ抜いて差し替えるだけなので簡単

基盤側かスイッチ側の個体差だと思うけど、スイッチのはまり具合がまちまちで、結構力を入れないと外れないスイッチがいくつかあって壊してしまわないかちょっと怖かった。逆にキーキャップを取ろうとしたらスイッチごと抜けてしまうということもあったり。まぁ慎重にやればよっぽどのことがないと壊れないはず。

スト6のトレモやランクマで数時間使ってみたけど、概ね思い描いていた押し心地になって満足。キーストロークが浅くなったので底を打つ時に指にかかる力が大きくなった気はするので手へのダメージは増えるかもしれない。

その後、キーキャップも買って変えてみた。こっちはアリエクで購入。見た目のカスタマイズだけのつもりだったけど、キャップの高さが Rushbox 純正のものよりも低かったので、押した時に天板ギリギリくらいの高さになってさらに押し心地が変わった。あと、表面の加工が梨地?になっててさらさらとした好みの触り心地になったのは嬉しい誤算。

キーキャップも変えた。30φボタンだけ緑

自作キーボードは大変そうだなーと思って HHKB とか Nuphy Air60 とかの既製品を使ってたけど、ホットスワップならスイッチも簡単に交換できるし、キャップだけ変えるのもお手軽に変化が出るので興味ができてきた。

スパンを手作りすることで 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日 の記事です。