Skip to content

Commit f62012d

Browse files
authored
feat: Support for pluggable metric reporting (#81)
1 parent 5f966b0 commit f62012d

File tree

9 files changed

+198
-3
lines changed

9 files changed

+198
-3
lines changed

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ lazy val docs = project
123123
.settings(dontPublish)
124124
.settings(
125125
name := "Akka Persistence plugin for Amazon DynamoDB",
126-
libraryDependencies ++= Dependencies.docs,
126+
libraryDependencies ++= (Dependencies.TestDeps.cloudwatchMetricPublisher +: Dependencies.docs),
127127
makeSite := makeSite.dependsOn(LocalRootProject / ScalaUnidoc / doc).value,
128128
previewPath := (Paradox / siteSubdirName).value,
129129
Preprocess / siteSubdirName := s"api/akka-persistence-dynamodb/${projectInfoVersion.value}",

core/src/main/scala/akka/persistence/dynamodb/util/ClientProvider.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import software.amazon.awssdk.core.retry.RetryPolicy
2525
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
2626
import software.amazon.awssdk.regions.Region
2727
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient
28+
import software.amazon.awssdk.metrics.MetricPublisher
2829

2930
object ClientProvider extends ExtensionId[ClientProvider] {
3031
def createExtension(system: ActorSystem[_]): ClientProvider = new ClientProvider(system)
@@ -35,6 +36,7 @@ object ClientProvider extends ExtensionId[ClientProvider] {
3536
class ClientProvider(system: ActorSystem[_]) extends Extension {
3637
private val clients = new ConcurrentHashMap[String, DynamoDbAsyncClient]
3738
private val clientSettings = new ConcurrentHashMap[String, ClientSettings]
39+
private val metricsProvider = AWSClientMetricsResolver.resolve(system)
3840

3941
CoordinatedShutdown(system)
4042
.addTask(CoordinatedShutdown.PhaseBeforeActorSystemTerminate, "close DynamoDB clients") { () =>
@@ -48,7 +50,7 @@ class ClientProvider(system: ActorSystem[_]) extends Extension {
4850
configLocation,
4951
configLocation => {
5052
val settings = clientSettingsFor(configLocation)
51-
createClient(settings)
53+
createClient(settings, metricsProvider.map(_.metricPublisherFor(configLocation)))
5254
})
5355
}
5456

@@ -63,7 +65,7 @@ class ClientProvider(system: ActorSystem[_]) extends Extension {
6365
}
6466
}
6567

66-
private def createClient(settings: ClientSettings): DynamoDbAsyncClient = {
68+
private def createClient(settings: ClientSettings, metricsPublisher: Option[MetricPublisher]): DynamoDbAsyncClient = {
6769
val httpClientBuilder = NettyNioAsyncHttpClient.builder
6870
.maxConcurrency(settings.http.maxConcurrency)
6971
.maxPendingConnectionAcquires(settings.http.maxPendingConnectionAcquires)
@@ -98,6 +100,8 @@ class ClientProvider(system: ActorSystem[_]) extends Extension {
98100
overrideConfigurationBuilder = overrideConfigurationBuilder.apiCallAttemptTimeout(timeout.toJava)
99101
}
100102

103+
metricsPublisher.foreach { mp => overrideConfigurationBuilder.addMetricPublisher(mp) }
104+
101105
overrideConfigurationBuilder.build()
102106
}
103107

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (C) 2024 Lightbend Inc. <https://www.lightbend.com>
3+
*/
4+
5+
package akka.persistence.dynamodb.util
6+
7+
import akka.actor.ClassicActorSystemProvider
8+
import akka.actor.ExtendedActorSystem
9+
import akka.annotation.ApiMayChange
10+
import akka.annotation.InternalApi
11+
12+
import software.amazon.awssdk.metrics.MetricCollection
13+
import software.amazon.awssdk.metrics.MetricPublisher
14+
15+
import scala.jdk.CollectionConverters.ListHasAsScala
16+
17+
import java.util.concurrent.ConcurrentHashMap
18+
19+
/**
20+
* Service Provider Interface for injecting AWS SDK MetricPublisher into the underlying DynamoDB client (see
21+
* https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/metrics-list.html).
22+
*
23+
* Implementations must include a single constructor with one argument: an `akka.actor.ClassicActorSystemProvider`. To
24+
* setup your implementation, add a setting to your 'application.conf':
25+
*
26+
* {{{
27+
* akka.persistence.dynamodb.client.metrics-providers += com.myexample.MyAWSMetricsProvider
28+
* }}}
29+
*/
30+
@ApiMayChange
31+
trait AWSClientMetricsProvider {
32+
33+
/**
34+
* Given an overall config path for Akka Persistence DynamoDB (e.g. 'akka.persistence.dynamodb') returns an instance
35+
* of an AWS SDK MetricPublisher which publishes SDK client metrics to the location of this implementation's choosing.
36+
*/
37+
def metricPublisherFor(configLocation: String): MetricPublisher
38+
}
39+
40+
/** INTERNAL API */
41+
@InternalApi
42+
private[dynamodb] object AWSClientMetricsResolver {
43+
def resolve(system: ClassicActorSystemProvider): Option[AWSClientMetricsProvider] = {
44+
val providersPath = "akka.persistence.dynamodb.client.metrics-providers"
45+
val config = system.classicSystem.settings.config
46+
if (!config.hasPath(providersPath)) {
47+
None
48+
} else {
49+
val fqcns = config.getStringList(providersPath)
50+
51+
fqcns.size match {
52+
case 0 => None
53+
case 1 => Some(createProvider(system, fqcns.get(0)))
54+
case _ =>
55+
val providers = fqcns.asScala.toSeq.map(fqcn => createProvider(system, fqcn))
56+
Some(new EnsembleAWSClientMetricsProvider(providers))
57+
}
58+
}
59+
}
60+
61+
def createProvider(system: ClassicActorSystemProvider, fqcn: String): AWSClientMetricsProvider = {
62+
system.classicSystem
63+
.asInstanceOf[ExtendedActorSystem]
64+
.dynamicAccess
65+
.createInstanceFor[AWSClientMetricsProvider](fqcn, List(classOf[ClassicActorSystemProvider] -> system))
66+
.get
67+
}
68+
69+
// This technically does not follow the construction convention that would allow it
70+
// to be reflectively constructed, but we don't reflectively construct it
71+
private class EnsembleAWSClientMetricsProvider(providers: Seq[AWSClientMetricsProvider])
72+
extends AWSClientMetricsProvider {
73+
private val instances = new ConcurrentHashMap[String, MetricPublisher]()
74+
75+
def metricPublisherFor(configLocation: String): MetricPublisher =
76+
instances.computeIfAbsent(
77+
configLocation,
78+
path =>
79+
new MetricPublisher {
80+
private val publishers = providers.map(_.metricPublisherFor(configLocation))
81+
82+
def publish(metricCollection: MetricCollection): Unit = {
83+
publishers.foreach(_.publish(metricCollection))
84+
}
85+
86+
def close(): Unit = {
87+
publishers.foreach(_.close())
88+
}
89+
})
90+
91+
}
92+
}

core/src/test/scala/akka/persistence/dynamodb/util/ClientProviderSpec.scala

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
package akka.persistence.dynamodb.util
66

77
import scala.concurrent.duration._
8+
import scala.jdk.CollectionConverters.ListHasAsScala
89
import scala.jdk.OptionConverters._
910

11+
import akka.actor.ClassicActorSystemProvider
1012
import akka.actor.testkit.typed.scaladsl.ActorTestKit
1113
import akka.actor.testkit.typed.scaladsl.ActorTestKitBase
1214
import akka.util.JavaDurationConverters._
@@ -17,6 +19,8 @@ import org.scalatest.wordspec.AnyWordSpec
1719
import software.amazon.awssdk.core.retry.RetryMode
1820
import software.amazon.awssdk.regions.Region
1921
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient
22+
import software.amazon.awssdk.metrics.MetricCollection
23+
import software.amazon.awssdk.metrics.MetricPublisher
2024

2125
class ClientProviderSpec extends AnyWordSpec with Matchers with OptionValues {
2226

@@ -62,6 +66,22 @@ class ClientProviderSpec extends AnyWordSpec with Matchers with OptionValues {
6266
compressionConfiguration.minimumCompressionThresholdInBytes shouldBe 10240
6367
}
6468

69+
"create client with a MetricPublisher" in withActorTestKit("""
70+
akka.persistence.dynamodb.client {
71+
region = "us-east-1"
72+
metrics-providers += akka.persistence.dynamodb.util.TestNoopMetricsProvider
73+
}
74+
""") { testKit =>
75+
val clientConfigLocation = "akka.persistence.dynamodb.client"
76+
val client = ClientProvider(testKit.system).clientFor(clientConfigLocation)
77+
78+
val clientConfiguration = client.serviceClientConfiguration
79+
val overrideConfiguration = clientConfiguration.overrideConfiguration
80+
val metricPublishers = overrideConfiguration.metricPublishers.asScala.toSeq
81+
metricPublishers.size shouldBe 1
82+
metricPublishers should contain(TestNoopMetricsProvider.publisher)
83+
}
84+
6585
"create client with configured settings" in withActorTestKit("""
6686
akka.persistence.dynamodb.client {
6787
call-timeout = 3 seconds
@@ -158,3 +178,15 @@ class ClientProviderSpec extends AnyWordSpec with Matchers with OptionValues {
158178
}
159179

160180
}
181+
182+
class TestNoopMetricsProvider(system: ClassicActorSystemProvider) extends AWSClientMetricsProvider {
183+
def metricPublisherFor(configLocation: String): MetricPublisher = TestNoopMetricsProvider.publisher
184+
}
185+
186+
object TestNoopMetricsProvider {
187+
val publisher =
188+
new MetricPublisher {
189+
def publish(collection: MetricCollection): Unit = ()
190+
def close(): Unit = ()
191+
}
192+
}

docs/src/main/paradox/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This Akka Persistence plugin allows for using Amazon DynamoDB as a backend for A
1313
* [Query Plugin](query.md)
1414
* [Projection](projection.md)
1515
* [Configuration](config.md)
16+
* [Observability](observability.md)
1617
* [Database cleanup](cleanup.md)
1718
* [Contributing](contributing.md)
1819

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Observability
2+
3+
This plugin supports injecting an [AWS `MetricPublisher`](https://github.com/aws/aws-sdk-java-v2/blob/master/docs/design/core/metrics/Design.md) into the underlying DynamoDB SDK client. This injection is accomplished by defining a class @scala[extending]@java[implementing] @apidoc[akka.persistence.dynamodb.util.AWSClientMetricsProvider].
4+
5+
Your implementation must expose a single constructor with one argument: an `akka.actor.ClassicActorSystemProvider`. Its `metricPublisherFor` method will take the config path to the `client` section of this instance of the plugin @ref:[configuration](config.md#multiple-plugins).
6+
7+
The AWS SDK provides an implementation of `MetricPublisher` which publishes to [Amazon CloudWatch](https://docs.aws.amazon.com/cloudwatch/). An `AWSClientMetricsProvider` providing [this `MetricPublisher`](https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/metrics.html) with defaults would look like:
8+
9+
Scala
10+
: @@snip [cloudwatch default](/docs/src/test/scala/docs/CloudWatchProvider.scala) { #cloudwatch-default }
11+
12+
Java
13+
: @@snip [cloudwatch default](/docs/src/test/java/jdocs/CloudWatchWithDefaultConfigurationMetricsProvider.java) { #cloudwatch-default }
14+
15+
To register your provider implementation with the plugin, add its fully-qualified class name to the configuration path `akka.persistence.dynamodb.client.metrics-providers` (e.g. in `application.conf`):
16+
17+
```
18+
akka.persistence.dynamodb.client.metrics-providers += domain.package.CloudWatchWithDefaultConfigurationMetricsProvider
19+
```
20+
21+
In a test case, it may be useful to set the entire list of `metrics-providers` explicitly:
22+
23+
```
24+
akka.persistence.dynamodb.client.metrics-providers = [ "domain.package.CloudWatchWithDefaultConfigurationMetricsProvider" ]
25+
```
26+
27+
If multiple providers are specified, they will automatically be combined into a "meta-provider" which provides a publisher which will publish using _all_ of the specified providers' respective publishers.
28+
29+
If implementing your own `MetricPublisher`, [Amazon recommends that care be taken to not block the thread calling the methods of the `MetricPublisher`](https://github.com/aws/aws-sdk-java-v2/blob/master/docs/design/core/metrics/Design.md#performance): all I/O and "heavy" computation should be performed asynchronously and control immediately returned to the caller.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package jdocs;
2+
3+
// #cloudwatch-default
4+
import akka.actor.ClassicActorSystemProvider;
5+
import akka.persistence.dynamodb.util.AWSClientMetricsProvider;
6+
import software.amazon.awssdk.metrics.MetricPublisher;
7+
import software.amazon.awssdk.metrics.publishers.cloudwatch.CloudWatchMetricPublisher;
8+
9+
public class CloudWatchWithDefaultConfigurationMetricsProvider implements AWSClientMetricsProvider {
10+
public CloudWatchWithDefaultConfigurationMetricsProvider(ClassicActorSystemProvider system) {
11+
}
12+
13+
@Override
14+
public MetricPublisher metricPublisherFor(String configLocation) {
15+
// These are just the defaults... a more elaborate configuration using its builder is possible
16+
return CloudWatchMetricPublisher.create();
17+
}
18+
}
19+
// #cloudwatch-default
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package docs
2+
3+
// #cloudwatch-default
4+
import akka.actor.ClassicActorSystemProvider
5+
import akka.persistence.dynamodb.util.AWSClientMetricsProvider
6+
import software.amazon.awssdk.metrics.MetricPublisher
7+
import software.amazon.awssdk.metrics.publishers.cloudwatch.CloudWatchMetricPublisher
8+
9+
class CloudWatchWithDefaultConfigurationMetricsProvider(system: ClassicActorSystemProvider)
10+
extends AWSClientMetricsProvider {
11+
def metricPublisherFor(configLocation: String): MetricPublisher = {
12+
// These are just the defaults... a more elaborate configuration using its builder is possible
13+
CloudWatchMetricPublisher.create()
14+
}
15+
}
16+
// #cloudwatch-default

project/Dependencies.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ object Dependencies {
4040
val scalaTest = "org.scalatest" %% "scalatest" % "3.2.12" % Test // ApacheV2
4141
val junit = "junit" % "junit" % "4.12" % Test // Eclipse Public License 1.0
4242
val junitInterface = "com.novocode" % "junit-interface" % "0.11" % Test // "BSD 2-Clause"
43+
44+
val cloudwatchMetricPublisher = "software.amazon.awssdk" % "cloudwatch-metric-publisher" % AwsSdkVersion % Test
4345
}
4446

4547
import Compile._

0 commit comments

Comments
 (0)