Skip to content

Commit

Permalink
emit DDB_MAPPER business metric (#1426)
Browse files Browse the repository at this point in the history
  • Loading branch information
ianbotsf authored Oct 3, 2024
1 parent 4240741 commit 535d4c3
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 7 deletions.
1 change: 1 addition & 0 deletions aws-runtime/aws-http/api/aws-http.api
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ public final class aws/sdk/kotlin/runtime/http/interceptors/AddUserAgentMetadata
}

public final class aws/sdk/kotlin/runtime/http/interceptors/AwsBusinessMetric : java/lang/Enum, aws/smithy/kotlin/runtime/businessmetrics/BusinessMetric {
public static final field DDB_MAPPER Laws/sdk/kotlin/runtime/http/interceptors/AwsBusinessMetric;
public static final field S3_EXPRESS_BUCKET Laws/sdk/kotlin/runtime/http/interceptors/AwsBusinessMetric;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public fun getIdentifier ()Ljava/lang/String;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,5 @@ private fun formatMetrics(metrics: MutableSet<String>): String {
@InternalApi
public enum class AwsBusinessMetric(public override val identifier: String) : BusinessMetric {
S3_EXPRESS_BUCKET("J"),
DDB_MAPPER("d"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public object MapperPkg {
public object Hl {
public val Base: String = "aws.sdk.kotlin.hll.dynamodbmapper"
public val Annotations: String = "$Base.annotations"
public val Internal: String = "$Base.internal"
public val Items: String = "$Base.items"
public val Model: String = "$Base.model"
public val Ops: String = "$Base.operations"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public object MapperTypes {
public val ManualPagination: TypeRef = TypeRef(MapperPkg.Hl.Annotations, "ManualPagination")
}

public object Internal {
public val withWrappedClient: TypeRef = TypeRef(MapperPkg.Hl.Internal, "withWrappedClient")
}

public object Items {
public fun itemSchema(typeVar: String): TypeRef =
TypeRef(MapperPkg.Hl.Items, "ItemSchema", genericArgs = listOf(TypeVar(typeVar)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ internal class OperationRenderer(
}
write("schema) },")

write("lowLevelInvoke = spec.mapper.client::#L,", operation.methodName)
withBlock("lowLevelInvoke = { lowLevelReq ->", "},") {
withBlock("spec.mapper.client.#T { client ->", "}", MapperTypes.Internal.withWrappedClient) {
write("client.#L(lowLevelReq)", operation.methodName)
}
}

write("deserialize = #L::convert,", operation.response.lowLevelName)
write("interceptors = spec.mapper.config.interceptors,")
}
Expand Down
1 change: 1 addition & 0 deletions hll/dynamodb-mapper/dynamodb-mapper/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
implementation(project(":aws-runtime:aws-http"))
api(project(":services:dynamodb"))
api(project(":hll:hll-mapping-core"))
api(libs.kotlinx.coroutines.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import aws.sdk.kotlin.hll.dynamodbmapper.DynamoDbMapper
import aws.sdk.kotlin.hll.dynamodbmapper.items.ItemSchema
import aws.sdk.kotlin.hll.dynamodbmapper.model.internal.tableImpl
import aws.sdk.kotlin.hll.dynamodbmapper.pipeline.InterceptorAny
import aws.sdk.kotlin.runtime.http.interceptors.AwsBusinessMetric
import aws.sdk.kotlin.services.dynamodb.DynamoDbClient
import aws.sdk.kotlin.services.dynamodb.withConfig
import aws.smithy.kotlin.runtime.businessmetrics.emitBusinessMetric
import aws.smithy.kotlin.runtime.client.RequestInterceptorContext
import aws.smithy.kotlin.runtime.http.interceptors.HttpInterceptor

internal data class DynamoDbMapperImpl(
override val client: DynamoDbClient,
Expand All @@ -35,3 +40,16 @@ internal class MapperConfigBuilderImpl : DynamoDbMapper.Config.Builder {

override fun build() = MapperConfigImpl(interceptors.toList())
}

/**
* An interceptor that emits the DynamoDB Mapper business metric
*/
private object BusinessMetricInterceptor : HttpInterceptor {
override suspend fun modifyBeforeSerialization(context: RequestInterceptorContext<Any>): Any {
context.executionContext.emitBusinessMetric(AwsBusinessMetric.DDB_MAPPER)
return context.request
}
}

internal inline fun <T> DynamoDbClient.withWrappedClient(block: (DynamoDbClient) -> T): T =
withConfig { interceptors += BusinessMetricInterceptor }.use(block)
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package aws.sdk.kotlin.hll.dynamodbmapper

import aws.sdk.kotlin.hll.dynamodbmapper.items.AttributeDescriptor
import aws.sdk.kotlin.hll.dynamodbmapper.items.ItemSchema
import aws.sdk.kotlin.hll.dynamodbmapper.items.KeySpec
import aws.sdk.kotlin.hll.dynamodbmapper.items.SimpleItemConverter
import aws.sdk.kotlin.hll.dynamodbmapper.operations.scanPaginated
import aws.sdk.kotlin.hll.dynamodbmapper.testutils.DdbLocalTest
import aws.sdk.kotlin.hll.dynamodbmapper.values.scalars.IntConverter
import aws.sdk.kotlin.hll.dynamodbmapper.values.scalars.StringConverter
import aws.sdk.kotlin.runtime.http.interceptors.AwsBusinessMetric
import aws.sdk.kotlin.services.dynamodb.scan
import aws.sdk.kotlin.services.dynamodb.withConfig
import aws.smithy.kotlin.runtime.businessmetrics.BusinessMetric
import aws.smithy.kotlin.runtime.businessmetrics.BusinessMetrics
import aws.smithy.kotlin.runtime.client.ProtocolRequestInterceptorContext
import aws.smithy.kotlin.runtime.collections.get
import aws.smithy.kotlin.runtime.http.interceptors.HttpInterceptor
import aws.smithy.kotlin.runtime.http.request.HttpRequest
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.test.runTest
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class DynamoDbMapperTest : DdbLocalTest() {
companion object {
private const val TABLE_NAME = "dummy"

private data class DummyData(var foo: String = "", var bar: Int = 0)

private val dummyConverter = SimpleItemConverter(
::DummyData,
{ this },
AttributeDescriptor("foo", DummyData::foo, DummyData::foo::set, StringConverter),
AttributeDescriptor("bar", DummyData::bar, DummyData::bar::set, IntConverter),
)

private val dummySchema = ItemSchema(dummyConverter, KeySpec.String("foo"), KeySpec.Number("bar"))
}

@BeforeAll
fun setUp() = runTest {
createTable(TABLE_NAME, dummySchema)
}

@Test
fun testBusinessMetricEmission() = runTest {
val interceptor = MetricCapturingInterceptor()

val ddb = lowLevelAccess { withConfig { interceptors += interceptor } }
interceptor.assertEmpty()

// No metric for low-level client
lowLevelAccess { scan { tableName = TABLE_NAME } }
interceptor.assertMetric(AwsBusinessMetric.DDB_MAPPER, exists = false)
interceptor.reset()

// Metric for high-level client
val mapper = mapper(ddb)
val table = mapper.getTable(TABLE_NAME, dummySchema)
table.scanPaginated { }.collect()
interceptor.assertMetric(AwsBusinessMetric.DDB_MAPPER)
interceptor.reset()

// Still no metric for low-level client (i.e., LL wasn't modified by HL)
lowLevelAccess { scan { tableName = TABLE_NAME } }
interceptor.assertMetric(AwsBusinessMetric.DDB_MAPPER, exists = false)
interceptor.reset()

// Original client can be closed, mapper is unaffected
lowLevelAccess { close() }
table.scanPaginated { }.collect()
interceptor.assertMetric(AwsBusinessMetric.DDB_MAPPER)
}
}

private class MetricCapturingInterceptor : HttpInterceptor {
private val capturedMetrics = mutableSetOf<String>()

override fun readBeforeTransmit(context: ProtocolRequestInterceptorContext<Any, HttpRequest>) {
capturedMetrics += context.executionContext[BusinessMetrics]
}

fun assertMetric(metric: BusinessMetric, exists: Boolean = true) {
if (exists) {
assertTrue(
metric.identifier in capturedMetrics,
"Expected metrics to contain $metric. Actual values: $capturedMetrics",
)
} else {
assertFalse(
metric.identifier in capturedMetrics,
"Expected metrics *not* to contain $metric. Actual values: $capturedMetrics",
)
}
}

fun assertEmpty() {
assertTrue(capturedMetrics.isEmpty(), "Expected metrics to be empty. Actual values: $capturedMetrics")
}

fun reset() {
capturedMetrics.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class PutItemTest : DdbLocalTest() {

table.putItem { item = Item(id = "foo", value = 42) }

val resp = ddb.getItem(TABLE_NAME, "id" to "foo")
val resp = lowLevelAccess { getItem(TABLE_NAME, "id" to "foo") }

val item = assertNotNull(resp.item)
assertEquals("foo", item["id"]?.asSOrNull())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ import aws.sdk.kotlin.hll.dynamodbmapper.DynamoDbMapper
import aws.sdk.kotlin.hll.dynamodbmapper.items.ItemSchema
import aws.sdk.kotlin.hll.dynamodbmapper.model.Item
import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
import aws.sdk.kotlin.runtime.http.interceptors.AwsBusinessMetric
import aws.sdk.kotlin.services.dynamodb.DynamoDbClient
import aws.sdk.kotlin.services.dynamodb.deleteTable
import aws.sdk.kotlin.services.dynamodb.waiters.waitUntilTableNotExists
import aws.smithy.kotlin.runtime.client.ProtocolRequestInterceptorContext
import aws.smithy.kotlin.runtime.http.interceptors.HttpInterceptor
import aws.smithy.kotlin.runtime.http.request.HttpRequest
import aws.smithy.kotlin.runtime.net.Host
import aws.smithy.kotlin.runtime.net.Scheme
import aws.smithy.kotlin.runtime.net.url.Url
import io.kotest.core.spec.style.AnnotationSpec
import kotlinx.coroutines.runBlocking
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

Expand All @@ -41,6 +46,9 @@ abstract class DdbLocalTest : AnnotationSpec() {
}
}

private val requests = mutableListOf<HttpRequest>()
private val requestInterceptor = RequestCapturingInterceptor(this@DdbLocalTest.requests)

private val ddbHolder = lazy {
DynamoDbClient {
endpointUrl = Url {
Expand All @@ -55,15 +63,20 @@ abstract class DdbLocalTest : AnnotationSpec() {
accessKeyId = "DUMMY"
secretAccessKey = "DUMMY"
}

interceptors += requestInterceptor
}
}

/**
* An instance of a low-level [DynamoDbClient] utilizing the DynamoDB Local instance which may be used for setting
* up or verifying various mapper tests. If this is the first time accessing the value, the client will be
* initialized.
*
* **Important**: This low-level client should only be accessed via [lowLevelAccess] to ensure that User-Agent
* header verification succeeds.
*/
val ddb by ddbHolder
private val ddb by ddbHolder

private val tempTables = mutableListOf<String>()

Expand Down Expand Up @@ -95,16 +108,58 @@ abstract class DdbLocalTest : AnnotationSpec() {
lsis: Map<String, ItemSchema<*>>,
items: List<Item>,
) {
ddb.createTable(name, schema, gsis, lsis)
tempTables += name
ddb.putItems(name, items)
lowLevelAccess {
createTable(name, schema, gsis, lsis)
tempTables += name
putItems(name, items)
}
}

/**
* Returns a [DynamoDbMapper] instance utilizing the DynamoDB Local instance
* @param config A function to set the configuration of the mapper before it's built
*/
fun mapper(config: DynamoDbMapper.Config.Builder.() -> Unit = { }) = DynamoDbMapper(ddb, config)
fun mapper(
ddb: DynamoDbClient? = null,
config: DynamoDbMapper.Config.Builder.() -> Unit = { },
) = DynamoDbMapper(ddb ?: this.ddb, config)

@BeforeEach
fun initializeTest() {
requestInterceptor.enabled = true
}

/**
* Executes requests on a low-level [DynamoDbClient] and _does not_ log any requests executed in [block]. (This
* skips verifying that low-level requests contain the [AwsBusinessMetric.DDB_MAPPER] metric.)
*/
protected suspend fun <T> lowLevelAccess(block: suspend DynamoDbClient.() -> T): T {
requestInterceptor.enabled = false
return block(ddb).also { requestInterceptor.enabled = true }
}

@AfterEach
fun postVerify() {
requests.forEach { req ->
val uaString = requireNotNull(req.headers["User-Agent"]) {
"Missing User-Agent header for request $req"
}

val components = uaString.split(" ")

val metricsComponent = requireNotNull(components.find { it.startsWith("m/") }) {
"""User-Agent header "$uaString" doesn't contain business metrics for request $req"""
}

val metrics = metricsComponent.removePrefix("m/").split(",")

assertContains(
metrics,
AwsBusinessMetric.DDB_MAPPER.identifier,
"""Mapper business metric not present in User-Agent header "$uaString" for request $req""",
)
}
}

@AfterAll
fun cleanUp() {
Expand All @@ -120,3 +175,13 @@ abstract class DdbLocalTest : AnnotationSpec() {
}
}
}

private class RequestCapturingInterceptor(val requests: MutableList<HttpRequest>) : HttpInterceptor {
var enabled = true

override fun readBeforeTransmit(context: ProtocolRequestInterceptorContext<Any, HttpRequest>) {
if (enabled) {
requests += context.protocolRequest
}
}
}

0 comments on commit 535d4c3

Please sign in to comment.