New Relic APM における分散トレーシングのサンプリング方法

New Relic の分散トレーシングはデフォルトでヘッドベースサンプリングが選択されています。

docs.newrelic.com

これに関しては業界として標準にもなっているので特に違和感はありませんが実際にどのようにしてサンプリングを実現させているのか、公式の回答ではありませんがソースコードを読むことでその部分の個人的な理解をまとめた記事を紹介します。 あくまでソースコードから読み取った個人の考察の結果になりますので正確な情報ではないことを留意して本記事を読んで頂ければと思います。

筆者が普段 Go を利用する関係で New Relic Go Agent を今回は利用していきますので言語ごとの実装差異はあると思いますのであくまで参考として見て頂ければと思います。

New Relic Go Agent とは

まず New Relic Go Agent (以下 Go Agent) とはについてですが New Relic APM を Go で利用する際のライブラリになります。 パフォーマンス情報やトレーシングなどを取得してエンジニアが視覚的にわかりやすくアプリケーションについての分析をできるようにするためのものになっています。

APM については New Relic APM の概要をご覧ください。

なぜサンプリングをするのか

そもそもですが何故全量を取得せずサンプリングを行うのでしょうか? 全て送ってしまえば全部のデータを見れるようになるのでデフォルトでは全てのデータを送るようにすることが望ましいと考えますが実際には一度メモリなどに取得したデータを保存して定期的に New Relic や外部のデータストアに対してデータを転送するため、アプリケーションへのオーバーヘッドなどのリスクがあります。

またサンプリングをしないことで全てのデータを送るようにして全量を送れるようになることは良いものの、今後はそこから問題のあった処理や実際に確認したい処理を探すことに時間がかかってしまうことも考えられます。もちろんサンプリングをすることによって必要な欲しかったものが取得できなかったということは少なからず起こり得るのでトレードオフの関係にあります。

狙ったものをピンポイントで取得したい場合はテールベースサンプリング、New Relic の場合 Infinite Tracing という機能があるのでそちらを利用することである程度コントロールできますが設定が別で必要になるなどあるので最初から設定することはオススメしません。

docs.newrelic.com

分散トレーシングのサンプリング

では実際に New Relic Go Agent でのサンプリング方法について紹介します。 まずドキュメント上ではこのような説明がされています。

https://docs.newrelic.com/jp/docs/distributed-tracing/concepts/how-new-relic-distributed-tracing-works/#trace-origin-sampling

当社のAPM言語エージェントは適応サンプリングを使用して、システムアクティビティの代表的なサンプルを取得します。適応サンプリングのしくみは次のとおりです。

トレース内の最初のサービスのスループットを使用して、リクエストのサンプリング頻度が調整されます。これについては以下で詳しく説明します。また、APMエージェントのドキュメントを参照することもできます。

ディストリビューティッド(分散)トレーシングで最初にモニターするサービスは、トレース元と呼ばれます。トレース元は、トレースが無作為になるようリクエストを選択します。この決定は、そのリクエストがタッチしたダウンストリームのサービスに伝搬されます。リクエストが完了すると、これらのリクエストによって生成されたスパンがNew Relicに報告され、完全なトレースとしてUIで利用可能になります(ただし、以下で説明するエージェントのスパン制限によってトレースが断片化する可能性があります)。

トレース元のサービスは、デフォルトで1分あたり10個のトレースをサンプリングします。その期間の代表的なサンプルを取得するため、これら10個のトレースの収集を1分間に分散しようとします。正確なサンプリングレートは、直前の1分間のトランザクション数に依存し、その後のスループットの変化に適応します。

たとえば、前の1分間に100件のトランザクションがあった場合、エージェントは同様の数のトランザクションを予測し、次の1分間にサンプリングするトランザクション10 件ごとに1件を選択します。

APMエージェントには1分あたりに収集されるスパンの数に制限があり、エージェントインスタンスごとに1分あたり収集されるスパンのデフォルト制限は2000です(これを調整する方法については、APMエージェント設定のドキュメントを参照してください)。エージェントが1分間に設定された制限を超えるスパンを生成すると、一部のスパンが削除され、UIでトレースが断片化されます。トレースは、サンプリング対象として選択される際にランダムな優先順位が割り当てられるため、複数のエージェントがスパンを削除する必要がある場合、最初に優先順位の低いトレースからスパンを削除して、優先順位の高いトレースをそのままの状態に保とうとします。

1分間のサイクルの中で10件のトレースを取得することを保証しようとする動きはありますがライフサイクルによってはサンプリングが調整されるような仕組みになっているようです。 実際にコードの実装がどうなっているのかを見てみようと思います。

Transaction の計測

New Relic APMはリクエストを Transaction というイベントで処理されます。 Go Agent の場合、StartTransaction という処理を呼び出すことで計測されるようになっています。

docs.newrelic.com

この StartTransaction がどのように実装されているか見てみると

func (app *Application) StartTransaction(name string, opts ...TraceOption) *Transaction {
    if app == nil {
        return nil
    }
    return app.app.StartTransaction(name, opts...)
}

https://github.com/newrelic/go-agent/blob/master/v3/newrelic/application.go#L19

このような実装になっています。 この StartTransaction 自体の実装は internal_app.go で実装されており

func newTransaction(thd *thread) *Transaction {
    return &Transaction{
        Private: thd,
        thread:  thd,
    }
}

func (app *app) StartTransaction(name string, opts ...TraceOption) *Transaction {
    if nil == app {
        return nil
    }
    run, _ := app.getState()
    return newTransaction(newTxn(app, run, name, opts...))
}

このような実装になっています。 https://github.com/newrelic/go-agent/blob/master/v3/newrelic/internal_app.go#L521-L535

newTxn という実装が様々な attribute などを付与してその後のサンプリングなどのための情報を付与しています。 https://github.com/newrelic/go-agent/blob/master/v3/newrelic/internal_txn.go#L104

その後処理を経過した後に End というメソッドを呼び出すことで Transaction の終了を Go Agent に知らせることで 1Transaction としての計測が終了する形となります。

func (txn *Transaction) End() {
    if txn == nil || txn.thread == nil {
        return
    }

    var r any
    if txn.thread.Config.ErrorCollector.RecordPanics {
        // recover must be called in the function directly being deferred,
        // not any nested call!
        r = recover()
    }
    if txn.thread.IsWeb && IsSecurityAgentPresent() {
        secureAgent.SendEvent("INBOUND_END", "")
    }
    txn.thread.logAPIError(txn.thread.End(r), "end transaction", nil)
}

また StartTransaction 同様に End の処理も internal_app.go の End という処理を呼び出しています

https://github.com/newrelic/go-agent/blob/master/v3/newrelic/internal_txn.go#L436-L550

この処理の中で lazilyCalculateSampled という処理によって最終的にサンプリングされるかどうかを決定しています。

https://github.com/newrelic/go-agent/blob/master/v3/newrelic/internal_txn.go#L195

func (txn *txn) lazilyCalculateSampled() bool {
    if !txn.BetterCAT.Enabled {
        return false
    }
    if txn.sampledCalculated {
        return txn.BetterCAT.Sampled
    }
    txn.BetterCAT.Sampled = txn.appRun.adaptiveSampler.computeSampled(txn.BetterCAT.Priority.Float32(), time.Now())
    if txn.BetterCAT.Sampled {
        txn.BetterCAT.Priority += 1.0
    }
    txn.sampledCalculated = true
    return txn.BetterCAT.Sampled
}

この処理の中で computeSampled という処理がありますがこちらの処理でサンプリングするかどうかを決定しているようです。

func (as *adaptiveSampler) computeSampled(priority float32, now time.Time) bool {
    as.Lock()
    defer as.Unlock()

    // Never sample anything if the target is zero.  This is not an expected
    // connect reply response, but it is used for the placeholder run (app
    // not connected yet), and is used for testing.
    if 0 == as.target {
        return false
    }

    // If the current time is after the end of the "currentPeriod".  This is in
    // a `for`/`while` loop in case there's a harvest where no sampling happened.
    // i.e. for situations where a single call to
    //    as.currentPeriod.end = as.currentPeriod.end.Add(as.period)
    // might not catch us up to the current period
    for now.After(as.currentPeriod.end) {
        as.priorityMin = 0.0
        if as.currentPeriod.numSeen > 0 {
            sampledRatio := float32(as.target) / float32(as.currentPeriod.numSeen)
            as.priorityMin = 1.0 - sampledRatio
        }
        as.currentPeriod.numSampled = 0
        as.currentPeriod.numSeen = 0
        as.currentPeriod.end = as.currentPeriod.end.Add(as.period)
    }

    as.currentPeriod.numSeen++

    // exponential backoff -- if the number of sampled items is greater than our
    // target, we need to apply the exponential backoff
    if as.currentPeriod.numSampled > as.target {
        if as.computeSampledBackoff(as.target, as.currentPeriod.numSeen, as.currentPeriod.numSampled) {
            as.currentPeriod.numSampled++
            return true
        }
        return false
    }

    if priority >= as.priorityMin {
        as.currentPeriod.numSampled++
        return true
    }

    return false
}

動作として大まかに説明すると約1分間のサイクルの中でサンプリングをするかどうかを決定しているのですがなるべく Agent 単位で10件の Trace を取得するように努めているようになっています。 computeSampledBackoff という処理で既にサンプリング数のターゲット(10件)を超えた際に調整しようとしている動きをしています。

func (as *adaptiveSampler) computeSampledBackoff(target uint64, decidedCount uint64, sampledTrueCount uint64) bool {
    return float64(randUint64N(decidedCount)) <
        math.Pow(float64(target), (float64(target)/float64(sampledTrueCount)))-math.Pow(float64(target), 0.5)
}

最終的には priority の数値によって決定していますがこれは Trace.id の生成時の値によるものとなっているようです。 このような形でサンプリングの有無を決定しているのが New Relic APM での分散トレースの仕組みとなっています。

ある意味どれくらい APM のあるアプリケーションに対してリクエストがきたのかという部分に左右もされる部分はあるのでコントロールしにくい部分はあるのかなというのが調べていた感想になっています。 New Relic は TDP といわれるデータの転送量に対して GB あたりで課金がされるようになっています。もしデータ量が多く、調整を効かせたい場合は Dropfilter というものを利用して転送されないようにする必要があります。

以上が New Relic APM の分散トレースにおいてのサンプリングの仕組みになります。