Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package org.lfdecentralizedtrust.splice.integration.tests

import org.lfdecentralizedtrust.splice.environment.SpliceMetrics.MetricsPrefix
import org.lfdecentralizedtrust.splice.util.AmuletConfigUtil
import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment
import com.digitalasset.canton.metrics.MetricValue
import org.lfdecentralizedtrust.splice.console.SvAppBackendReference

import scala.jdk.OptionConverters.*

class SvTimeBasedAmuletPriceIntegrationTest
extends SvTimeBasedIntegrationTestBaseWithIsolatedEnvironment
with AmuletConfigUtil {

"amulet price votes and metrics" in { implicit env =>
initDso()

advanceTimeToRoundOpen
advanceRoundsByOneTick

val svParties =
Seq(sv1Backend, sv2Backend, sv3Backend, sv4Backend).map {
_.getDsoInfo().svParty.toProtoPrimitive
}

clue("initially only sv1 and sv2 have set the AmuletPriceVote") {
// sv1 because it initialized the DSO and sv2 because we configured it to do so
eventually() {
checkPrices(svParties, Seq(Some(0.005), Some(0.005), None, None))
}
}

val votedPrices = Seq(Some(0.005), Some(0.3), Some(0.1), Some(0.2))
actAndCheck(
"svs 2-4 vote on new prices", {
sv2Backend.updateAmuletPriceVote(BigDecimal(0.3))
sv3Backend.updateAmuletPriceVote(BigDecimal(0.1))
sv4Backend.updateAmuletPriceVote(BigDecimal(0.2))
},
)(
"All SV backends see the new prices",
_ =>
Seq(sv1Backend, sv2Backend, sv3Backend, sv4Backend).foreach {
svSeesPrices(_, svParties, votedPrices)
},
)

advanceRoundsByOneTick
advanceRoundsByOneTick

clue("Metrics are updated") {
eventually() {
checkPrices(svParties, votedPrices)
}
}
}

private def svSeesPrices(
svBackend: SvAppBackendReference,
svParties: Seq[String],
prices: Seq[Option[Double]],
) = {
svParties.zip(prices).foreach {
case (sv, Some(price)) =>
svBackend
.listAmuletPriceVotes()
.filter(_.payload.sv == sv)
.foreach(_.payload.amuletPrice.toScala.value.doubleValue() shouldBe price)
case _ =>
}

}

private def checkPrices(svParties: Seq[String], prices: Seq[Option[Double]])(implicit
env: SpliceTestConsoleEnvironment
): Unit = {
clue("Check individual SV votes") {
svParties.zip(prices).foreach {
case (sv, Some(price)) =>
sv1Backend.metrics.list(
s"$MetricsPrefix.amulet_price.voted_price",
Map("sv" -> sv),
) should not be empty

sv1Backend.metrics
.get(
s"$MetricsPrefix.amulet_price.voted_price",
Map("sv" -> sv),
)
.select[MetricValue.DoublePoint]
.value
.value shouldBe price
case (sv, _) =>
sv1Backend.metrics.list(
s"$MetricsPrefix.amulet_price.voted_price",
Map("sv" -> sv),
) shouldBe empty
}
}

clue("Check price in latest round") {
val expectedRoundPrice = median(prices.filter(_.isDefined).map(p => BigDecimal(p.value)))
sv1Backend.metrics
.get(
s"$MetricsPrefix.amulet_price.latest_open_round_price"
)
.select[MetricValue.DoublePoint]
.value
.value shouldBe expectedRoundPrice.value
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import org.lfdecentralizedtrust.splice.automation.TriggerMetrics
import org.lfdecentralizedtrust.splice.scan.store.db.DbScanStoreMetrics
import org.lfdecentralizedtrust.splice.scan.metrics.ScanMediatorVerdictIngestionMetrics
import org.lfdecentralizedtrust.splice.sv.automation.singlesv.SequencerPruningMetrics
import org.lfdecentralizedtrust.splice.sv.automation.ReportSvStatusMetricsExportTrigger
import org.lfdecentralizedtrust.splice.sv.automation.{
AmuletPriceMetricsTrigger,
ReportSvStatusMetricsExportTrigger,
}
import org.lfdecentralizedtrust.splice.sv.store.db.DbSvDsoStoreMetrics
import org.lfdecentralizedtrust.splice.store.{DomainParamsStore, HistoryMetrics, StoreMetrics}
import org.lfdecentralizedtrust.splice.validator.metrics.TopologyMetrics
Expand Down Expand Up @@ -99,6 +102,7 @@ object MetricsDocs {
ReportSvStatusMetricsExportTrigger.SvId(svParty.toProtoPrimitive, "svName"),
generator,
)
new AmuletPriceMetricsTrigger.AmuletPriceMetrics(generator)
val svMetrics = generator.getAll()
generator.reset()
// scan
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package org.lfdecentralizedtrust.splice.sv.automation

import com.daml.metrics.api.MetricHandle.{Gauge, LabeledMetricsFactory}
import com.daml.metrics.api.MetricQualification.Debug
import com.daml.metrics.api.{MetricInfo, MetricName, MetricsContext}
import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, SyncCloseable}
import com.digitalasset.canton.tracing.TraceContext
import io.opentelemetry.api.trace.Tracer
import org.apache.pekko.stream.Materializer

import scala.concurrent.{ExecutionContext, Future, blocking}
import org.lfdecentralizedtrust.splice.automation.{PollingTrigger, TriggerContext}
import org.lfdecentralizedtrust.splice.environment.SpliceMetrics
import org.lfdecentralizedtrust.splice.sv.automation.AmuletPriceMetricsTrigger.AmuletPriceMetrics
import org.lfdecentralizedtrust.splice.sv.store.SvDsoStore

import scala.collection.concurrent.TrieMap
import scala.jdk.OptionConverters.*

class AmuletPriceMetricsTrigger(
override protected val context: TriggerContext,
dsoStore: SvDsoStore,
)(implicit
override val ec: ExecutionContext,
override val tracer: Tracer,
val mat: Materializer,
) extends PollingTrigger {

private val amuletPriceMetrics = new AmuletPriceMetrics(context.metricsFactory)

override def performWorkIfAvailable()(implicit traceContext: TraceContext): Future[Boolean] =
for {
currentVotes <- dsoStore.listSvAmuletPriceVotes()
_ = currentVotes.foreach { vote =>
{
vote.payload.amuletPrice.toScala match {
case Some(price) => amuletPriceMetrics.updateSvAmuletPrice(vote.payload.sv, price)
case None =>
logger.debug(s"SV ${vote.payload.sv} has not voted on a price yet")
}
}
}
_ = amuletPriceMetrics.closeAllOffboardedSvMetrics(currentVotes.map(_.payload.sv))
latestRound <- dsoStore.lookupLatestUsableOpenMiningRound(context.clock.now)
_ = latestRound match {
case Some(round) => amuletPriceMetrics.updateLatestRoundPrice(round.payload.amuletPrice)
case None =>
logger.debug(s"No usable open mining round yet")
}

} yield false

override def closeAsync(): Seq[AsyncOrSyncCloseable] = super
.closeAsync()
.appended(SyncCloseable("amulet price metrics", { amuletPriceMetrics.close() }))
}

object AmuletPriceMetricsTrigger {
case class AmuletPriceMetrics(metricsFactory: LabeledMetricsFactory) extends AutoCloseable {

private val prefix: MetricName = SpliceMetrics.MetricsPrefix :+ "amulet_price"

private val svAmuletPrices: TrieMap[String, Gauge[Double]] = TrieMap.empty
private val latestOpenRoundPrice = metricsFactory.gauge(
MetricInfo(
prefix :+ "latest_open_round_price",
"The price in the latest open round",
Debug,
),
Double.NaN,
)(MetricsContext.Empty)

private def getSvStatusMetrics(sv: String): Gauge[Double] =
svAmuletPrices.getOrElse(
sv,
// We must synchronize here to avoid allocating the metrics for the same sv multiple times, which would lead to
// duplicate metric labels being reported by OpenTelemetry.
blocking {
synchronized {
svAmuletPrices.getOrElseUpdate(
sv,
metricsFactory.gauge(
MetricInfo(
prefix :+ "voted_price",
"The latest price that this SV has voted on",
Debug,
),
Double.NaN,
)(MetricsContext.Empty.withExtraLabels("sv" -> sv)),
)
}
},
)

def updateSvAmuletPrice(sv: String, price: BigDecimal): Unit = {
getSvStatusMetrics(sv).updateValue(price.doubleValue)
}
def updateLatestRoundPrice(price: BigDecimal): Unit = {
latestOpenRoundPrice.updateValue(price.doubleValue)
}

def closeAllOffboardedSvMetrics(svs: Seq[String]): Unit = {
val svIdsToClose = svAmuletPrices.keySet.toSet -- svs
svAmuletPrices.view.filterKeys(svIdsToClose.contains).foreach(_._2.close())
blocking {
synchronized {
svAmuletPrices --= svIdsToClose: Unit
}
}
}

override def close(): Unit = {
svAmuletPrices.values.foreach(_.close())
latestOpenRoundPrice.close()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,12 @@ class SvDsoAutomationService(
connection(SpliceLedgerConnectionPriority.Low),
)
)
registerTrigger(
new AmuletPriceMetricsTrigger(
triggerContext,
dsoStore,
)
)

config.scan.foreach { scan =>
registerTrigger(
Expand Down Expand Up @@ -515,5 +521,6 @@ object SvDsoAutomationService extends AutomationServiceCompanion {
aTrigger[SvBftSequencerPeerOffboardingTrigger],
aTrigger[SvBftSequencerPeerOnboardingTrigger],
aTrigger[FollowAmuletConversionRateFeedTrigger],
aTrigger[AmuletPriceMetricsTrigger],
)
}
5 changes: 5 additions & 0 deletions docs/src/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ Upcoming
- Published conversion rates are now clamped to the configured range and the clamped value is published instead of
only logging a warning and not publishing an updated value for out of range values.

- Monitoring

- The SV App now exposes metrics for SV-voted coin prices and the coin price in latest open mining round.


0.4.20
------

Expand Down
1 change: 1 addition & 0 deletions test-full-class-names-sim-time.log
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ org.lfdecentralizedtrust.splice.integration.tests.FollowAmuletConversionRateFeed
org.lfdecentralizedtrust.splice.integration.tests.ScanTimeBasedIntegrationTest
org.lfdecentralizedtrust.splice.integration.tests.ScanWithGradualStartsTimeBasedIntegrationTest
org.lfdecentralizedtrust.splice.integration.tests.SvExpiredRewardsCollectionTimeBasedIntegrationTest
org.lfdecentralizedtrust.splice.integration.tests.SvTimeBasedAmuletPriceIntegrationTest
org.lfdecentralizedtrust.splice.integration.tests.SvTimeBasedBootstrappingRoundIntegrationTest
org.lfdecentralizedtrust.splice.integration.tests.SvTimeBasedOnboardingIntegrationTest
org.lfdecentralizedtrust.splice.integration.tests.SvTimeBasedRewardCouponIntegrationTest
Expand Down
Loading