kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

「スパン数を数えるくん」を作ってみた

「トレースのスパン数がどのくらいになりそうか」の見積りは難しいことがある。

ゼロコード計装であったとしてもどのようなスパンが生成されるかは設計と実装によるし、シンプルに作っているつもりでもN+1問題を内包していればスパン数はふくれ上がる。結局のところ、「やってみないと見積れない」という答えになりがちだ(見積りとは?)。

そこで、スパン数を見積ることだけを目的に、シンプルなSpan Report Collector、通称「スパン数を数えるくん」を作ってみた。

github.com

使い方

リリースページからOSおよびアーキテクチャに合ったバイナリアーカイブをダウンロードし、スパン数を数えたいアプリケーションがあるホストで展開する。

展開した中にあるspan-report-collectorが実行ファイルで、デフォルト設定であれば引数不要でそのまま実行すればよい。

デフォルトではOpenTelemetryの標準準拠でHTTP/4318とgRPC/4317をローカルホストアドレスで待ち受けるようになっている。もしローカルではなくほかのホストからのトレーススパンを受け取って数えたいときには、環境変数SPAN_REPORT_OTLP_ENDPOINT_HTTPSPAN_REPORT_OTLP_ENDPOINT_GRPCを指定して実行すればよい。

SPAN_REPORT_OTLP_ENDPOINT_HTTP=0.0.0.0:4318 ./span-report-collector

残念ながら、この「スパン数を数えるくん」はあくまでも送られてきたトレーススパンの数を数えるだけであり、アプリケーションにトレース送信機能が自動的に構成されるというものではない。

ゼロコード計装なりOBIなり手動計装なりでトレーススパンを送るように構成する作業が必要だ。送り先はOpenTelemetry Collector(およびその先のオブザーバビリティプラットフォーム)の代わりに「スパン数を数えるくん」となる。

ゼロコード計装およびOBIについては過去に記事にしていたので参照されたい。

kmuto.hatenablog.com

kmuto.hatenablog.com

また、よりまとまったOBIの説明を執筆してMackerelブログにて公開している。

mackerel.io

レポートの見方

デフォルトはTUIモードで、topコマンド風に都度更新される状況画面が表示される。

サービス(および環境)ごとに、時・日・月でのスパン数累計が示される。スパン数は普通に受け取ったスパン数のほか、その内訳としてHTTPスパン(Kind=SERVERおよび、http.routeまたはhttp.target属性を持つスパン)、SQLスパン(db.query.textまたはdb.statement属性を持つスパン)も出している。

TUIで表示している値は時・日・月の切り替わりで0リセットされることになるので、ぱっと見のわかりやすさに反して統計レポートとしては扱いにくい。裏側ではspan_report.txt(デフォルトの出力先ファイル)にレポートログが1時間ごとに書き出されているので、実際の利用見積りではこれを確認することになるだろう。

[2025-12-18 08:59:59] service:order-api, env:prod | Hourly(Total:1500, HTTP:1000, SQL:500) | Daily(Total:34200, HTTP:20000, SQL:14200) | Monthly(Total:120500, HTTP:80000, SQL:40500)
[2025-12-18 08:59:59] service:auth-svc, env:dev | Hourly(Total:120, HTTP:0, SQL:0) | Daily(Total:800, HTTP:0, SQL: 0) | Monthly(Total:5200, HTTP:0, SQL:0)

今後の予定

Geminiに壁打ちしながら作ってみたものだが、TUIおよびレポートファイルの見た目や表示すべき情報については、もっと改善できないかとは思っている。

  • 日の次の単位を月としているが、週もほしくなりそうだ
  • トレース数もほしくなるか
  • TUIよりも自由度の高いWeb UIがほしくなるだろうか。しかしあまり変にポートを開きたくないし、何らかの脆弱性を呼び込むのは避けたい

あまり盛り込んでも「そこまでやるなら本命のオブザーバビリティプラットフォームか、otel-tui・Jaeger・SigNozあたりに送ったほうがいいのでは?」になりそうなので、悩むところである。

開発の裏話

最後に、工夫どころや気付いたことなどを書き記しておく。

仕組みとしてはカスタムOpenTelemetry Collector

「OpenTelemetryのトレーススパンをOTLPで受けて(receiver)、それを手頃なレポートでエクスポートしたい(exporter)」

これを最短距離で最速に実現するために、レポートExporterを書き、それをocbでカスタムOpenTelmetry Collectorとしてビルドする方法を選択した。

processorなど諸々追加し、設定を切り替えるだけでそのまま本番運用できたら便利なのでは?」ということも少々検討したが、スパン数を数えるくんはあくまで見積ることに特化して、うっかり課金要素などにつながらないようにしたいという思いがあって見送っている。otlpexporterは含めているので、設定ファイルを書いた上で転送することは可能だ。

OTLPサポートライブラリはデカい

「Collectorを流用するのではなく、OTLPを受けてレポートを作ることに特化したほうがコンパクトになるのではないか?」という仮説で別の実装も試していた。

しかし、HTTP/JSONだけを受け取るならともかく、一般的なHTTP/ProtobufやgRPCを受容したり、gzip圧縮に対応したり等々していくと、取り込むライブラリの関係でCollectorとさして違いがないものになる(特にHTTP/Protobufは大きい気がする)。

  • HTTP/ProtobufでOTLPを待ち受けるだけのバイナリ:17MB
  • 「スパン数を数えるくん」バイナリ:42MB

中途半端に自作するよりも、実績のあるCollectorをそのまま流用するのが妥当だろう。

リッチなTUI vs Ambiguous Width

TUIにはbubbleteaライブラリを使っている(推しということもなくGeminiの提案のまま)。

当初は「border table」のレイアウトで作っていのだが、罫線がいわゆる「あいまい幅(Ambiguous Width)」になるため、全角設定にしているLinuxGnomeターミナルで悲惨な状態になった。Gnomeターミナル、文字単位で調整できないものか…。

また、サービス名と時・日・月を列で収めようとすると幅80文字には収めづらくなったので、KやMなども使いながら列がずれにくくなるようにしたりと試行錯誤している。TUIのレイアウトはまだしっくりとはきていない。

また、Collectorの起動時INFOログが画面を破壊するので、TUIモードではtelemetry.logs.levelのデフォルトをERRORレベルとするよう仕込んでいる。

ゼロコンフィグへのこだわり

デフォルトとして設定したい内容は決まっているのに、ocbで作るCollectorは--config引数での設定を必須としている。これは気に入らない

対策として、ocbで作ったmain.goを加工し、embedでデフォルト設定ファイルを実行バイナリに埋め込んで、--config指定がなければそれを読み込むようにした。embedは初めて使った。

//go:embed default_config.yaml
var defaultConfigFile []byte

...

if useDefault {
        // 1. Load embedded file content
        rawYaml := string(defaultConfigFile)

バインドアドレスやインターバルを変えたいだけなのに完全なYAMLファイルを用意するのはバカげているので、埋め込んでいるデフォルト設定ファイルのパラメータを環境変数で置換してから利用する仕掛けにしている(こう見ると、環境変数とファイル内パラメータがところどころ違うのは気になってくるが…)。

        replacer := strings.NewReplacer(
            "{{OTLP_ENDPOINT_GRPC}}", getEnv("SPAN_REPORT_OTLP_ENDPOINT_GRPC", "localhost:4317"),
            "{{OTLP_ENDPOINT_HTTP}}", getEnv("SPAN_REPORT_OTLP_ENDPOINT_HTTP", "localhost:4318"),
            "{{TUI_ENABLED}}", getEnv("SPAN_REPORT_TUI", "true"),
            "{{REPORT_PATH}}", getEnv("SPAN_REPORT_PATH", "./span_report.txt"),
            "{{REPORT_INTERVAL}}", getEnv("SPAN_REPORT_INTERVAL", "1h"),
            "{{VERBOSE_LOGGING}}", getEnv("SPAN_REPORT_VERBOSE", "false"),
            "{{LOG_LEVEL}}", loglevel,
        )

        // 3. Perform all replacements
        finalYaml := replacer.Replace(rawYaml)