OpenTelemetryの計装ライブラリ比較 for database/sql – Cybozu Inside Out

はじめに

CloudPlatform部のpddgです。GoにおけるOpenTelemetryの計装ライブラリ、特にdatabase/sqlパッケージのトレーシングを行うためのライブラリにはデファクトスタンダードと呼べるものがありません。本記事では、いくつかのサードパーティーライブラリの機能を比較しまとめました。採用したライブラリは実際に本番環境のサービスの計装に利用しています。
この内容は、2025年に開催されたサマーインターンシップにおいて、プラットフォーム(自社基盤)コースに参加して頂いた柳田さんにお手伝い頂いてまとめたものです。

実際に採用したライブラリを用いると、わずかなコード追加で以下の様なトレースが取得できるようになります。横棒が各ステップにかかった時間を表し、各ステップごとにその引数やメタデータを表示できます。障害発生時などにはこれらの情報を活用してアプリケーションの内部で実際に発生していた状況をデバッグできるようになります。

背景

サイボウズでは、多数のアプリケーション・ミドルウェアがKubernetesベースのインフラ基盤上で動作しています。複数のマイクロサービスが他のマイクロサービスやデータベースを呼び出しており、問題が発生した際にどのサービスの何が原因なのかを特定することは容易ではありません。このような問題の特定に役立てるため、サービス間の呼び出しを追跡して可視化する分散トレーシング技術が一般的に導入されるようになってきました。サイボウズでもOpenTelemetryを用いた分散トレーシングの導入に取り組んでおり*1、我々のチームでは主にGo言語で書かれたアプリケーションへの計装を行っています。

他言語のOpenTelemetry SDKには自動計装というアプリケーションコードを変更せずに計測を行う仕組みが提供されているものもありますが、Go言語のOpenTelemetry SDKには自動計装の仕組みがありません*2。そのため、アプリケーションコードにおいて明示的に計測コードを追加する必要があります*3

net/httpやgRPCなどの主要な機能には計装ライブラリが提供されており*4、比較的容易に計装できます。しかし、Go言語の標準ライブラリであるdatabase/sqlに対するモジュールは提供されておらず、サードパーティ製の計装ライブラリの利用を検討していました。今回のインターンシップでは、これまで後回しにしてしまっていたサービスにおける分散トレーシングの導入および計装ライブラリの選定に取り組んでいただきました。

サンプルコードの実装

我々の主なユースケースとしては、database/sqlとgithub.com/jmoiron/sqlxを利用し、MySQLにアクセスするアプリケーションがほとんどです。
また、*sql.DB.ExecContext*sql.DB.QueryContextなどは利用せず、ほとんどの場合でトランザクションを利用していることからその部分にフォーカスを絞っています。
サンプルコードとして簡単なTODOアプリのAPIを実装しました。リポジトリは以下にあります。

このコードに対して、各ライブラリを用いて計装を行うコードを例示します。

実際のインターンシップでは我々の内部サービスのコードを用いて計装を行ってもらっており、その際のコードやスクリーンショットがあるのですが、公開に当たってメンター側でサンプルコードに差し替えさせて頂いています。ご了承ください。

比較するライブラリ

今回は候補としていくつかのライブラリをピックアップし、実際にローカルでの計装を行っていただきました。

  1. github.com/uptrace/opentelemetry-go-extra/otelsql
  2. github.com/XSAM/otelsql
  3. go.nhatio.otelsql

本記事ではあくまで我々の現在のニーズを満たすため分散トレーシングにフォーカスした比較を行っています。これらのライブラリにはOpenTelemetryのメトリクス収集に関する機能も含まれていますが、詳細な比較は行っていません。

uptraceはOpenTelemetryを活用したオブザーバビリティプラットフォームの商用サービスを提供している企業です。
https://uptrace.dev/

このモジュールはuptraceがOSSとして公開して提供しているもので、ドキュメントでも紹介されています。

2025/9時点でStar数はこの中では2番目に多く、351でした。メンテナンス自体は頻繁に行われているわけではないようで、uptrace/opentelemetry-go-extra自体の最終コミットが2024年11月であることはやや気になる点です。

計装方法

db, err := otelsql.Open("mysql", dsn,
    otelsql.WithAttributes(semconv.DBSystemNameMySQL),
    otelsql.WithDBName("todoapp"),
)
if err != nil {
    return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()

todoService := NewTodoService(sqlx.NewDb(db, "mysql"))

github.com/uptrace/opentelemetry-go-extra/otelsqlx というモジュールも存在します。これは上記のようなotelsql.Openしてsqlx.NewDbでラップする実装を提供しているだけです。

取得できる情報

TODO登録時のトレースの例

  • db.Connect
  • db.Ping
  • db.Begin
  • db.Query
  • db.Exec
  • db.Prepare
  • stmt.Exec
  • stmt.Query
  • tx.Commit
  • tx.Rollback

などの操作に対してspanが作成されます。ほとんど柔軟性はなく、変更可能なのはspanへの属性の追加など一部のものに限られます。
実行されたstatementは db.statement としてSpanに追加されます。Prepareされたstatementの場合も同様であり、(適切にプレースホルダを利用していれば)入力されたパラメータが意図せずSpanに含まれることはありません。
取得可能な場合にはrows_affected を取得しているのが特徴的でした。

クエリのパラメータをSpanに追加する機能は探した限りでは見つかりませんでした。必要であれば自身でspanに追加する必要があります。

2. github.com/XSAM/otelsql

この実装は元々opentelemetry-go-contribに貢献されようとしていたものが、結果的に取り込まれず独自にホストされたものです。

https://github.com/XSAM/otelsql?tab=readme-ov-file#why-port-this

Based on this comment, OpenTelemetry SIG team like to see broader usage and community consensus on an approach before they commit to the level of support that would be required of a package in contrib. But it is painful for users without a stable version, and they have to use replacement in go.mod to use this instrumentation.

2025/9時点でStar数はこの中では最も多く356であり、現在もXSAMさんがメンテナンスを続けられています。

計装方法

otelsqlを使って特定のdriverでデータベースをOpenします。otelsql.WithAttributes でSpanに追加で付与する属性を指定できます。以下の例ではDBSystemをMySQLに設定しています。

db, err := otelsql.Open("mysql", dsn,
    otelsql.WithAttributes(semconv.DBSystemNameMySQL),
)
if err != nil {
    return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()

todoService := NewTodoService(sqlx.NewDb(db, "mysql"))

取得できる情報

TODO登録時のトレースの例

多くのステップに対してspanが作成されます。

  • sql.connector.connect
  • sql.conn.reset_session
  • sql.conn.exec
  • sql.conn.query
  • sql.conn.prepare
  • sql.conn.begin_tx
  • sql.conn.commit
  • sql.conn.rollback
  • sql.stmt.exec
  • sql.stmt.query
  • sql.rows

また、送信するspanをより細かくオプションで制御できるようになっています。詳しくは otelsql.SpanOptions を参照すると良いでしょう。
https://github.com/XSAM/otelsql/blob/7ca825674bad2598e7e78b502986d3ed27ca0337/config.go#L107-L151

デフォルトでは以下のspanは有効になっていません。

  • sql.conn.ping
  • sql.rows.next

特にRows.Nextは大量のデータを扱う場合にspanも大量に作成されてしまうため、有効化は慎重に検討する必要があります。

実行されるstatementは db.statement としてSpanに追加されます*5。Prepareされたstatementの場合も同様であり、(適切にプレースホルダを利用していれば)入力されたパラメータが意図せずSpanに含まれることはありません。

クエリに加えてパラメータを取得したい場合は自身でspanに追加するための AttributeGetter を実装する必要があります。
https://pkg.go.dev/github.com/XSAM/otelsql#AttributesGetter

func attributeGetter(ctx context.Context, method otelsql.Method, query string, args []driver.NamedValue) []attribute.KeyValue {
    if len(args) == 0 {
        return nil
    }
    attrs := make([]attribute.KeyValue, len(args))
    for i, arg := range args {
        argName := arg.Name
        if argName == "" {
            argName = strconv.Itoa(arg.Ordinal)
        }
        attrs[i] = semconv.DBQueryParameter(argName, fmt.Sprintf("%v", arg.Value))
    }
    return attrs
}
db, err := otelsql.Open("mysql", dsn,
    otelsql.WithAttributesGetter(attributeGetter),
)

クエリパラメータをspanに追加する例

driver.NamedValueName フィールドは名前付きパラメータ(sql.Named)を利用している場合にその名前が入ります。これは jmoiron/sqlxNamed* 系のメソッドがサポートする機能とは異なるものであることに注意してください。
MySQLでは名前付きパラメータはサポートされていない*6ため、sql.Named を使うとエラーが発生します。そのため、driver.NamedValue.Name を使って特定の名前のパラメータ(例えば password )だけは値をマスクする、といった実装はできません。

注意点

go-sql-driver/mysql は内部で driver.ErrSkip を利用することがあるのですが、github.com/XSAM/otelsql はこれをエラーとしてSpanに記録してしまいます。正常な挙動の中で呼び出されうるエラーであるため、これをエラーとして記録すると真に重要なエラーを見逃してしまう可能性があります。

DisableErrSkip: falseの場合にエラーとなる例

otelsql.SpanOptions.DisableErrSkiptrue に設定することでこの挙動を無効化できます。
他のDBMSドライバでも同様の問題があるかもしれません。

db, err := otelsql.Open("mysql", dsn,
    otelsql.WithAttributes(semconv.DBSystemNameMySQL),
    otelsql.WithSpanOptions(otelsql.SpanOptions{
        DisableErrSkip: true,
    }),
)

3. go.nhat.io/otelsql

リポジトリは以下にあります。

今回検討に上げたものの中では最もStar数が少なく、2025/9時点で119でした。dependabotによる更新以外のコミットは2025/4に行われていますが積極的に機能開発が行われているわけではないようです。

計装方法

このライブラリは otelsql.Register を使って新しいドライバ名を登録し、そのドライバ名を使って sql.Open します。

driverName, err := otelsql.Register("mysql",
    otelsql.WithSystem(semconv.DBSystemNameMySQL),
)
if err != nil {
    return fmt.Errorf("failed to register otelsql driver: %w", err)
}
db, err := sql.Open(driverName, dsn)
if err != nil {
    return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()

todoService := NewTodoService(sqlx.NewDb(db, "mysql"))

sqlxを使う場合、 sqlx.Open を使ってはならないと書かれています。これはsqlxがドライバ名を元に Named* 系のメソッドで使うプレースホルダを決定するため、利用するデータベースと異なるドライバ名を指定したときに正しく動作しないことがあるためです((github.com/XSAM/otelsqlにもdriverをRegisterする関数があり、これを使う場合は同様の問題を踏むケースがあります。)。

https://github.com/nhatthm/otelsql?tab=readme-ov-file#jmoironsqlx

Do not use the sqlx.Open and sqlx.Connect methods. jmoiron/sqlx uses the driver name to figure out which database is being used. It uses this knowledge to convert named queries to the correct bind type (dollar sign, question mark) if named queries are not supported natively by the database.

取得できる情報

TODO登録時のトレースの例

デフォルトでは以下のspanが作成されます。

  • sql:begin_transaction
  • sql:exec
  • sql:query
  • sql:prepare
  • sql:commit
  • sql:rollback

以下のspanはオプションで有効化できます。

  • sql:last_insert_id
  • sql:rows_affected
  • sql:ping
  • sql:rows_close
  • sql:rows_next

また、クエリやパラメータをSpanに追加するかどうかもオプションで制御できます。

  • otelsql.WithQueryWithArgs()
    • クエリとパラメータをSpanに追加する
  • otelsql.WithQueryWithoutArgs()
    • クエリのみをSpanに追加
  • otelsql.WithQuery(func(ctx context.Context, query string, args []driver.NamedValue) []attribute.KeyValue)
    • クエリとパラメータをSpanに追加するための関数を指定する

以下は WithQuery を使って otelsql.WithQueryWithArgs() に近い挙動を実装した例です。

func traceQuery(ctx context.Context, query string, args []driver.NamedValue) []attribute.KeyValue {
    attrs := make([]attribute.KeyValue, 0, len(args)+1)
    attrs = append(attrs, semconv.DBQueryText(query))
    for _, arg := range args {
        argName := arg.Name
        if argName == "" {
            argName = strconv.Itoa(arg.Ordinal)
        }
        attrs = append(attrs, semconv.DBQueryParameter(argName, fmt.Sprintf("%v", arg.Value)))
    }
    return attrs
}
driverName, err := otelsql.Register("mysql",
    otelsql.WithSystem(semconv.DBSystemNameMySQL),
    otelsql.WithQuery(traceQuery),
)

クエリパラメータをspanに追加する例

注意点

github.com/XSAM/otelsql と同様に、MySQLドライバの driver.ErrSkip をエラーとしてSpanに記録してしまいます。以下の様にオプションを設定することでこの挙動を無効化できます。

driverName, err := otelsql.Register("mysql",
    otelsql.WithSystem(semconv.DBSystemNameMySQL),
    otelsql.DisableErrSkip(),
)

どれを選ぶべきか

様々な観点から比較してみましたが、いくつかの観点から今回は github.com/XSAM/otelsql を採用することにしました。実際に本番環境のサービスの計装に利用しています。

  • 取得できる情報が豊富であり、spanの粒度を細かく制御できる
  • 現在もメンテナンスが続けられている
  • 必要であればクエリパラメータをSpanに追加できる
  • OTEL_SEMCONV_STABILITY_OPT_IN 環境変数に対応している*7

チームによって要件は異なると思いますが、乗り換えが非常に高コストなライブラリでも無いと思うので、気に入ったもの採用してみても良いのではないかと思いました。

クエリパラメータをSpanに追加する場合の注意点

機能として実現は可能なものの、どのようなケースでも有効化してよいものではないと考えています。以下に注意点を挙げます。

パラメータのマスキングや選択的トレーシング

クエリパラメータをSpanに追加する場合、パスワードなどの機密情報が含まれる可能性があることに注意が必要です。これを防ぐためには、 AttributeGetter として渡す関数の中でパラメータを選択的に追加したり、マスキングしたりする必要があります。

しかし前述したように、GoのMySQLドライバは名前つきパラメータをサポートしていません。クエリ本体を解析したりしないかぎり、どのパラメータが機密情報であるかを特定することは困難です。よって、そのまま使う限りパラメータをSpanに追加する場合は全てのパラメータを追加するか、全く追加しないかのどちらかを選択することになるでしょう。

AttributeGettercontext.Context を受け取ることから、何らかのトレースに関するヒントを含めてクエリ実行することでパラメータのスパンへの追加を取捨選択できるようにしたいと考えてはいますが、まだ良いインターフェースは思いついていません。

sqlxの Named* で複数行のINSERTをする場合

sqlxNamedExec は複数行のINSERTをサポートしています。例えば以下のようなコードです。

todoMaps := make([]map[string]any, len(todos))
for i, todo := range todos {
    todoMaps[i] = map[string]any{
        "title":       todo.Title,
        "description": todo.Description,
    }
}
query := `INSERT INTO todos (title, description, created_at)
VALUES (:title, :description, NOW())`
result, err := tx.NamedExecContext(ctx, query, todoMaps)
if err != nil {
    return 0, fmt.Errorf("failed to insert todos: %w", err)
}

この場合、 todoMaps の要素数に応じて ? プレースホルダが展開され、パラメータが設定されます。例えば todoMaps が3件のデータを持っていれば、 query は以下のようになります。

INSERT INTO todos (title, description, created_at)
VALUES (?, ?, NOW()), (?, ?, NOW()), (?, ?, NOW())

本記事で紹介したクエリパラメータをSpanに追加する方法を使うと、 db.statement に上記のようなクエリが追加され、さらに6つのパラメータがSpanに追加されます。

よって多数のパラメータを扱う場合、XSAM/otelsqlやgo.nhat.io/otelsqlでパラメータをSpanに追加するようにしていると、非常に多くの属性がSpanに追加されてしまいます。これを防ぐためには、 AttributeGetterWithQuery で渡す関数の中で記録するパラメータの数を制限するなどの工夫が必要です。

sqlxの sqlx.In を使う場合

sqlx.In を使うと、SQLクエリの中に ? プレースホルダが複数含まれる場合にそれを展開してくれます。例えば以下のようなコードです。

query, args, err := sqlx.In("SELECT id, title, description, created_at FROM todos WHERE id IN (?) ORDER BY created_at DESC", ids)
if err != nil {
    return nil, fmt.Errorf("failed to build IN query: %w", err)
}
var todos []Todo
if err := tx.SelectContext(ctx, &todos, query, args...); err != nil {
    return nil, fmt.Errorf("failed to get todos: %w", err)
}

この場合 sqlx.Inids の要素数に応じて ? プレースホルダを展開し、 args にその値を設定します。例えば ids[1, 2, 3] であれば、 querySELECT id, title, description, created_at FROM todos WHERE id IN (?,?,?) ORDER BY created_at DESC となり、 args1, 2, 3 となります。

バッチインサートする場合と同様に、 sqlx.In を使うケースでも多数のパラメータがspanに追加されてしまう可能性があることに注意が必要です。

まとめ

CloudPlatform部でインターンシップを開催し、学生の方にGo言語のOpenTelemetry計装ライブラリを比較検討していただきました。その成果を元に、最終的に我々のニーズに最も合致していると考えられるライブラリを採用することとしました。

この記事では本筋から外れてしまうため紹介し切れていませんが、柳田さんには実際に稼働している高トラフィックなサービスに対して計装を行っていただき大変助かりました。

CloudPlatform部では引き続き運用しやすいサービスの実現を目指し、オブザーバビリティの向上にも積極的に取り組んでいきます。興味があれば是非以下の採用情報もご覧ください。

また、昨年のインターンシップの様子や取り組んだ課題については以下からご覧頂けます。ご興味あれば是非ご覧ください。





元の記事を確認する

関連記事