Skip to content

可観測性

本番で必要な可視化は 4 系統あります:

系統載るものOP 側の差し込み口組み込み側の責務
運用ログリクエストエラー、設定の問題、起動時の状態WithLogger(*slog.Logger)構造化ログの送り先
監査イベント発火するシグナルと予約語(カタログWithAuditLogger(*slog.Logger)SOC パイプライン
メトリクスOIDC の業務カウンタWithPrometheus(*prometheus.Registry)/metrics ルート
トレースリクエストスパン内蔵なしhttp.Handlerotelhttp.NewMiddleware でラップ

ライブラリはこれらを意図的に分離しています。任意のサブセットだけを組み込むことができます。

何が出て、どこへ届くか
OP はプロトコル層の監査イベントを出し、その外側の HTTP 層はリクエスト ID とトレーススパンを出します。運用基盤がその両方を、運用ログ・監査ログ・メトリクス・トレースへ振り分けます。出す側届く先自前の HTTP 層OP を既に包んでいるミドルウェアリクエスト ID · トレーススパン · status · latencyOP のハンドラプロトコル処理だけを担う監査イベント — 判断 1 件につき 1 つ運用ログこのリクエストで何が起きたか監査ログ誰に何がいつ許可されたかPrometheusエンドポイントごとの流量とレイテンシOpenTelemetryOP が収まっているスパン
本ライブラリは logger も registry も exporter も持ちません。イベントを出すだけで、振り分けは既存の基盤に任せます。だから OP は既存のダッシュボードの隣ではなく、その中に現れます。

構造化ログ

go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

op.New(
    /* ... */
    op.WithLogger(logger),
)

運用ログがカバーする範囲:

  • 起動時の設定警告。
  • エンドポイント内部のエラー(典型的には IsServerError(err) が一致するもの — エラーカタログ を参照)。
  • ストアバックエンドの障害。

WithLogger を渡さない場合、ライブラリはログを破棄します(slog.Default() へのフォールバックはしません)。渡したハンドラは redaction ミドルウェアで自動的にラップされ、OAuth/OIDC のシークレットらしい属性(access_tokenrefresh_tokencodecode_verifierclient_secretstatenoncedpopauthorizationcookieset-cookie …)はハンドラに到達する前にマスクされます。

監査ログ

監査イベントは保管期間・索引・アクセス制御が運用ログとは違うので、独立した出力先(sink)にします:

go
auditFile, _ := os.OpenFile("/var/log/op/audit.jsonl",
    os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
auditLogger := slog.New(slog.NewJSONHandler(auditFile, nil))

op.New(
    /* ... */
    op.WithAuditLogger(auditLogger),
)

各イベントは、event 属性に "<event.name>" を持ち、msg は人間向けの短いメッセージとして、共通属性(request_idsubjectclient_idextras)も持つ JSON 1 行として記録されます。全イベント名は 監査イベントカタログ を参照してください。WithAuditLogger の handler は同期的に request goroutine 上で実行されます。遅い I/O や無制限 queue で block させず、多数の request から並行して呼ばれても安全にしてください。カタログには OP が発火しない予約語もあるため、すべての定数を発火の証拠として扱わないでください。

1 本のストリームを複数の出力先へ

1 つの *slog.Logger を fan-out 用のハンドラに通せば、ファイル + Loki + Splunk に同時に流せます。OP は自身で slog.Record を構築し、logger の Handler().Handle(...) に直接渡します。logger.LogAttrs(...) でイベントを作り直すことはありません。これにより WithClock が供給した監査 timestamp が保たれます。

introspection の store 障害

opaque access token または refresh token の lookup が store.ErrNotFound を返す場合は通常の inactive 結果であり、障害イベントは発火しません。それ以外の lookup error では、store_unavailable reason を付けた error level の AuditIntrospectionError (introspection.error) を発火します。/introspect の通信路上の応答は HTTP 200 と {"active":false} のままなので、アラートと調査では 5xx counter ではなく監査ストリームを参照してください。

Prometheus メトリクス

go
reg := prometheus.NewRegistry()

op.New(
    /* ... */
    op.WithPrometheus(reg),
)

// /metrics は好きなルートにマウント。通常は公開 OP のリスナでは
// なく、認可境界の裏に置く。
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
go http.ListenAndServe("127.0.0.1:9090", mux)

ライブラリは /metrics をマウントしません。ルートと境界は組み込み側が選択します。カウンタは監査カタログと同じ範囲に絞られています(厳選した部分集合。examples/52-prometheus-metrics を参照)。

OP が出さないメトリクス

これらは OP ではなく HTTP ミドルウェアの領域です:

  • HTTP リクエスト時間ヒストグラム — OP のハンドラの周りに promhttp.InstrumentHandlerDuration をかぶせてください。
  • HTTP ステータスコードカウンタ — 同上。
  • in-flight リクエストゲージ — 同上。

組み込みパターン:

go
inFlight := prometheus.NewGauge(prometheus.GaugeOpts{
    Name: "op_http_requests_in_flight",
})
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
    Name: "op_http_request_duration_seconds",
}, []string{"code", "method"})
reg.MustRegister(inFlight, duration)

instrumented := promhttp.InstrumentHandlerInFlight(inFlight,
    promhttp.InstrumentHandlerDuration(duration, opHandler))

http.Handle("/", instrumented)

この分離があるため、HTTP 層を差し替え(chi、gin、fiber など)ても、OP 側のメトリクスには手を入れる必要がありません。

トレース

OP は http.Handler を返します。OpenTelemetry の HTTP ミドルウェアで 1 度だけラップしてください:

go
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

http.Handle("/", otelhttp.NewHandler(opHandler, "oidc-op"))

スパンを作るのは otelhttp.NewHandler などのミドルウェアであって、OP ではありません。本ライブラリは、リクエストのライフサイクルも業務処理も含め、OpenTelemetry のスパンを一切内蔵しません。/token 内の PKCE 検証にどれだけかかったかといった段階別のトレースが必要なら、組み込み側で計装してください。監査イベントカタログが提供するのはスパンではなく、離散的なイベント記録です。

リクエスト ID

OP は X-Request-IDTraceparent ヘッダからリクエスト ID を抽出することはなく、自ら ID を生成することもありません。

監査 Event 構造体には相関用の RequestID フィールドがありますが、OP がこれを自動で埋めることはありません。リクエストとレスポンスの相関付けは、トレースで HTTP 層全般について述べたのと同じく、組み込み側の責務です。

エッジでリクエスト ID を生成し、レスポンスに刻んで RP のログと相関を取れるようにし、OP の周りで組み込む自前のログでも相関キーとして使ってください:

go
http.Handle("/", requestIDMiddleware(opHandler))

func requestIDMiddleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rid := r.Header.Get("X-Request-ID")
        if rid == "" {
            rid = newUUID()
            r.Header.Set("X-Request-ID", rid)
        }
        w.Header().Set("X-Request-ID", rid)
        h.ServeHTTP(w, r)
    })
}

推奨ダッシュボード

最低限の本番ダッシュボードは以下を出します:

パネルソースアラート閾値
grant_type 別のトークン発行レートPrometheus カウンタベースライン比 50 % 以上の持続的な低下
/token の 5xx 率HTTP ミドルウェアのヒストグラム5 分窓で 0.5 % 超
refresh.replay_detected の発生率監査ログ → Loki / ES0 超(非ゼロ自体が要調査)
bcl.no_sessions_for_subject の発生率監査ログ発生率の上昇を grant / client 状態と相関し、snapshot の文脈は別途確認
アクティブセッション数ストアのクエリまたは自前メトリクス30 % 超の低下
JWKS リクエスト率HTTP ミドルウェアRP キャッシュの健全性指標

最初の 3 つが本番異常の S/N が高い指標です。bcl.no_sessions_for_subject の発生率が上昇したら、運用者は grant / client 状態と突き合わせて原因を絞り込んでください。設定した posture は logout trigger と session snapshot の永続性を読み解くための文脈であり、空の RP audience の原因ではありません。残り 2 つは RP 側の regression を捕まえます。

レート制限と濫用シグナル

本ライブラリは /authorize/par/token/userinfo その他の公開エンドポイントに対して、組み込みのレート制限を 同梱していませんWithRateLimit のようなオプションも存在しません。レート制限は運用側の役割です — リバースプロキシ(NGINX、Envoy、Traefik)、エッジサービス(Cloudflare、Fastly)、あるいは *op.Providerhttp.Handler を実装)の前段ミドルウェアが、IP 単位・クライアント単位のリクエスト枠を持つべき場所です。ライブラリ側にあるかのように書くのは誤った期待を生むだけです。

ライブラリが代わりに発行しているのは、実際の制限がどこに置かれていても濫用パイプラインが消費して攻撃者をスコアリングできる、構造化された監査イベント群です:

監査イベント定数何を示すか
pkce.violationop.AuditPKCEViolation(予約語)カタログ名は予約されています。OP は PKCE 拒否を通信路上で返しますが、この監査イベントは発火しません。
redirect_uri.mismatchop.AuditRedirectURIMismatch(予約語)カタログ名は予約されています。OP は mismatch を通信路上で返しますが、この監査イベントは発火しません。
client_authn.failureop.AuditClientAuthnFailure/token/par/device_authorization/bc-authorize でのクライアント認証失敗。/introspect は別の op.AuditIntrospectionError を使い、/revoke はどちらも使いません。
device_code.verification.user_code_brute_forceop.AuditDeviceCodeUserCodeBruteForceデバイスコード確認画面で user_code が総当たりされている。レコードに紐付く helper はレコード単位で制限し、最初の手入力画面を複数レプリカで動かす場合は共有 limiter と devicecodekit.VerifyUserCodeByAttemptKey を使います — 確認 helper は Device Code を参照。
ciba.poll_abuse.lockoutop.AuditCIBAPollAbuseLockoutCIBA のポーリングカウンタが auth_req_id 単位の閾値を超えた。ライブラリは通信路上の応答を access_denied で拒否し、SOC ツールが相関できるようイベントを発行します。
rate_limit.exceededop.AuditRateLimitExceeded組み込み側のレート制限ミドルウェアから発火させるための予約イベント。ライブラリは定数だけ定義してカタログの一貫性を保ちますが、内部経路から発火させはしません。
rate_limit.bypassedop.AuditRateLimitBypassed同じ趣旨 — 組み込み側ミドルウェアが明示的な bypass(許可リスト済み IP、内部プローブ等)を記録するためのもの。

最後の 2 つは、ライブラリが運用側ミドルウェアと共有する語彙であって、内部イベントではありません。リバースプロキシやミドルウェアが枠を執行してリクエストを通した(あるいは拒否した)ときに、同じ監査ロガーへこれらを発行すれば、解析パイプラインが 1 本の一貫したストリームを観測できます。

推奨パターン

  1. リバースプロキシまたはエッジサービスでグローバルなレートを執行する — 例えば /token は IP 当たり 100 req/s、/par はもっと低めに。これが攻撃者にリクエストを「コスト」として支払わせる実機構です。
  2. 監査パイプラインがバーストにアラートを出す — 単一 IP・ASN・client_id から client_authn.failure が立て続けに来たとき。これは「誰かが OP を突いている」ことを示す S/N の高いイベントです。予約語である PKCE と redirect mismatch は現在 OP から発火しません。
  3. クライアント単位の許可リストで攻撃面を上流で削る。 WithCORSOriginsredirect_uris レジストリも許可リストとして働きます — 小さく保つほど、設定ミスや侵害されたクライアントが晒す攻撃面は減ります。

ライブラリが内部でロックアウトしている挙動

汎用の IP 単位レート制限では塞げない 3 つの濫用経路だけは、ライブラリ側にロックアウトが組み込まれています — 攻撃者は IP を安価に切り替えられ、保護対象がエントロピーの低いコードや subject 単位の credential(任意の URL ではない)だからです:

  • ログインのブルートフォース(要素跨ぎ)。 op.WithAuthnLockoutStore を組み込むと、組み込みの第 2 要素 Step(TOTP、メール OTP、パスワード)が同じ subject 単位カウンタを参照するので、攻撃者が要素を切り替えて推測予算を 2 倍にすることはできません。primary/additional factor の失敗は op.AuditLoginFailed または op.AuditMFAFailed、成功は op.AuditLoginSuccess または op.AuditMFASuccess になります。op.AuditLockoutStalled は CAS 競合で失敗試行がカウントされずに中断されたことを示します。
  • Device Code user_code の総当たり。 レコードに紐付く helper では op.devicecodekit が record ごとの strike カウンタを持ちます。最初の手入力画面で device_code が無い場合は、opaque で安定した key と共有 AttemptLimiter を使って VerifyUserCodeByAttemptKey を呼びます。ミスマッチごとに op.AuditDeviceCodeUserCodeBruteForce が発火し、レプリカごとに予算が分かれることを防げます。
  • CIBA poll abuse。 トークンエンドポイントは単一 auth_req_id が許容ケイデンスを超えてポーリングされた回数を数えます。閾値を超えると、リクエストストアの Denyreason="poll_abuse" で呼び、通信路上の応答は access_denied になり、op.AuditCIBAPollAbuseLockout が発火します。グローバルなレート制限で行儀の良い RP まで巻き添えにすることなく、病的な RP だけを止められます。

これらのゲートはいずれも 専用の 監査イベントを発火します — rate_limit.exceeded / rate_limit.bypassed ではありません。この分担は意図的です:

区分執行する側観測されるイベント
汎用 HTTP レート制限(IP 単位、エンドポイント単位、client_id 単位)組み込み側ミドルウェア(リバースプロキシ、ゲートウェイ、Go ハンドラチェーン)op.AuditRateLimitExceeded / op.AuditRateLimitBypassed — 組み込み側が OP の監査ロガーに発火させる
用途特化のブルートフォース防御(ログイン、user_code、ポーリングケイデンス)ライブラリ内部op.AuditLoginFailedop.AuditDeviceCodeUserCodeBruteForceop.AuditCIBAPollAbuseLockout

それ以外 — client_authn 失敗の継続や、通信路上で返る PKCE / redirect_uri 拒否 — には組み込みの HTTP リミッタがありません。client-auth 失敗は監査シグナルですが、PKCE と redirect mismatch は予約語です。これは意図的な分担です。OP は所有するシグナルを発行する側、運用側はそれに対する応答(ブロック、スロットル、ページング)を決める側、という切り分けです。

ログ保持

系統典型保持期間
運用7 〜 30 日
auditコンプライアンス要件次第。典型的には 1 〜 7 年
メトリクス高解像度で 30 〜 90 日、その後はロールアップ

監査ログはロングテールでコストの掛かる系統です。保管はそれ用に分離して見積もってください。1 イベント自体は小さいですが、高負荷な OP では量が多くなります。