はじめに
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を実装しました。リポジトリは以下にあります。
このコードに対して、各ライブラリを用いて計装を行うコードを例示します。
実際のインターンシップでは我々の内部サービスのコードを用いて計装を行ってもらっており、その際のコードやスクリーンショットがあるのですが、公開に当たってメンター側でサンプルコードに差し替えさせて頂いています。ご了承ください。
比較するライブラリ
今回は候補としていくつかのライブラリをピックアップし、実際にローカルでの計装を行っていただきました。
本記事ではあくまで我々の現在のニーズを満たすため分散トレーシングにフォーカスした比較を行っています。これらのライブラリには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
でラップする実装を提供しているだけです。
取得できる情報
- 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"))
取得できる情報
多くのステップに対して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),
)
driver.NamedValue
の Name
フィールドは名前付きパラメータ(sql.Named
)を利用している場合にその名前が入ります。これは jmoiron/sqlx
の Named*
系のメソッドがサポートする機能とは異なるものであることに注意してください。
MySQLでは名前付きパラメータはサポートされていない*6ため、sql.Named
を使うとエラーが発生します。そのため、driver.NamedValue.Name
を使って特定の名前のパラメータ(例えば password
)だけは値をマスクする、といった実装はできません。
注意点
go-sql-driver/mysql は内部で driver.ErrSkip
を利用することがあるのですが、github.com/XSAM/otelsql はこれをエラーとしてSpanに記録してしまいます。正常な挙動の中で呼び出されうるエラーであるため、これをエラーとして記録すると真に重要なエラーを見逃してしまう可能性があります。
otelsql.SpanOptions.DisableErrSkip
を true
に設定することでこの挙動を無効化できます。
他の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
andsqlx.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.
取得できる情報
デフォルトでは以下の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),
)
注意点
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に追加する場合は全てのパラメータを追加するか、全く追加しないかのどちらかを選択することになるでしょう。
AttributeGetter
が context.Context
を受け取ることから、何らかのトレースに関するヒントを含めてクエリ実行することでパラメータのスパンへの追加を取捨選択できるようにしたいと考えてはいますが、まだ良いインターフェースは思いついていません。
sqlxの Named*
で複数行のINSERTをする場合
sqlx
の NamedExec
は複数行の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に追加されてしまいます。これを防ぐためには、 AttributeGetter
や WithQuery
で渡す関数の中で記録するパラメータの数を制限するなどの工夫が必要です。
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.In
は ids
の要素数に応じて ?
プレースホルダを展開し、 args
にその値を設定します。例えば ids
が [1, 2, 3]
であれば、 query
は SELECT id, title, description, created_at FROM todos WHERE id IN (?,?,?) ORDER BY created_at DESC
となり、 args
は 1, 2, 3
となります。
バッチインサートする場合と同様に、 sqlx.In
を使うケースでも多数のパラメータがspanに追加されてしまう可能性があることに注意が必要です。
まとめ
CloudPlatform部でインターンシップを開催し、学生の方にGo言語のOpenTelemetry計装ライブラリを比較検討していただきました。その成果を元に、最終的に我々のニーズに最も合致していると考えられるライブラリを採用することとしました。
この記事では本筋から外れてしまうため紹介し切れていませんが、柳田さんには実際に稼働している高トラフィックなサービスに対して計装を行っていただき大変助かりました。
CloudPlatform部では引き続き運用しやすいサービスの実現を目指し、オブザーバビリティの向上にも積極的に取り組んでいきます。興味があれば是非以下の採用情報もご覧ください。
また、昨年のインターンシップの様子や取り組んだ課題については以下からご覧頂けます。ご興味あれば是非ご覧ください。