Skip to content

Commit

Permalink
Add graphql query request and resoponse sizes
Browse files Browse the repository at this point in the history
  • Loading branch information
kailyak committed Apr 17, 2024
1 parent 18b3d60 commit 1291438
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 11 deletions.
2 changes: 2 additions & 0 deletions graphql-dgs-spring-boot-micrometer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ dependencies {
implementation("com.netflix.spectator:spectator-api:1.7.+")
implementation("com.github.ben-manes.caffeine:caffeine")
implementation("org.springframework:spring-context-support")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

compileOnly(project(":graphql-dgs-spring-boot-starter"))
compileOnly("org.springframework.boot:spring-boot-actuator-autoconfigure")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ object DgsMetrics {
* This is useful if you want to find data loaders that might be responsible for poor query performance.
*/
DATA_LOADER("gql.dataLoader"),

/**
* _DistributionSummary_ that captures the size of a graphql query in a request.
* Measures size in bytes of the query and variables.
*/
QUERY_REQUEST_SIZE("gql.query.request.size"),

/**
* _DistributionSummary_ that captures the size of the result of a graphql query returned in a response.
* Measures size in bytes of the data, errors, and extensions.
*/
QUERY_RESPONSE_SIZE("gql.query.response.size"),
}

/** Defines the tags applied to the [GqlMetric] emitted by the framework. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.netflix.graphql.dgs.metrics.micrometer

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.netflix.graphql.dgs.Internal
import com.netflix.graphql.dgs.internal.DgsSchemaProvider
import com.netflix.graphql.dgs.metrics.DgsMetrics.GqlMetric
Expand Down Expand Up @@ -29,16 +31,27 @@ import graphql.schema.DataFetcher
import graphql.schema.GraphQLNamedType
import graphql.schema.GraphQLTypeUtil
import graphql.validation.ValidationError
import io.micrometer.core.instrument.DistributionSummary
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Tag
import io.micrometer.core.instrument.Timer
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.boot.actuate.metrics.AutoTimer
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder
import java.io.ByteArrayOutputStream
import java.util.Optional
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionStage
import kotlin.jvm.optionals.getOrNull
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

class DgsGraphQLMetricsInstrumentation(
private val schemaProvider: DgsSchemaProvider,
Expand All @@ -52,6 +65,8 @@ class DgsGraphQLMetricsInstrumentation(

companion object {
private val log: Logger = LoggerFactory.getLogger(DgsGraphQLMetricsInstrumentation::class.java)
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mapper: ObjectMapper = Jackson2ObjectMapperBuilder().modules(kotlinModule()).build()
}

@Deprecated("Deprecated in Java")
Expand Down Expand Up @@ -93,6 +108,14 @@ class DgsGraphQLMetricsInstrumentation(
): CompletableFuture<ExecutionResult> {
require(state is MetricsInstrumentationState)

try {
applicationScope.launch(CoroutineName("captureGqlQueryResponseSizeMetric-${state.operationNameValue}")) {
captureGqlQueryResponseSizeMetric(executionResult, parameters, state)
}
} catch (exc: Exception) {
log.warn("Exception thrown while attempting to serialize and measure response size of graphql data.")
}

val errorTagValues = ErrorUtils.sanitizeErrorPaths(executionResult)
if (errorTagValues.isNotEmpty()) {
val baseTags = buildList {
Expand Down Expand Up @@ -204,6 +227,15 @@ class DgsGraphQLMetricsInstrumentation(
if (properties.tags.complexity.enabled) {
state.queryComplexityValue = ComplexityUtils.resolveComplexity(parameters)
}

try {
applicationScope.launch(CoroutineName("captureGqlQueryRequestSizeMetric-${state.operationNameValue}")) {
captureGqlQueryRequestSizeMetric(parameters.executionContext.executionInput, state)
}
} catch (exc: Exception) {
log.warn("Exception thrown while attempting to serialize and measure request size of graphql data.")
}

return super.beginExecuteOperation(parameters, state)
}

Expand All @@ -225,6 +257,51 @@ class DgsGraphQLMetricsInstrumentation(
)
}

private suspend fun captureGqlQueryRequestSizeMetric(executionInput: ExecutionInput,
state: MetricsInstrumentationState) = coroutineScope {
val tags = buildList { addAll(state.tags()) }

val requestSizeMeter = DistributionSummary.builder(GqlMetric.QUERY_REQUEST_SIZE.key)
.description("Tracks graphql query request size.")
.baseUnit("bytes")
.tags(tags)
.publishPercentiles(0.90, 0.95, 0.99)
.register(registrySupplier.get())

launch {
val gqlQuerySize: Deferred<Int> = async { MeasurementUtils.serializeAndMeasureSize(executionInput.query) }
val gqlVariablesSize: Deferred<Int> = async { MeasurementUtils.serializeAndMeasureSize(executionInput.rawVariables) }

val totalSize = gqlQuerySize.await() + gqlVariablesSize.await()

requestSizeMeter.record(totalSize.toDouble())
}
}

private suspend fun captureGqlQueryResponseSizeMetric(executionResult: ExecutionResult, parameters: InstrumentationExecutionParameters,
state: MetricsInstrumentationState) = coroutineScope {
val tags = buildList {
addAll(state.tags())
addAll(tagsProvider.getExecutionTags(state, parameters, executionResult, null))
}

val responseSizeMeter = DistributionSummary.builder(GqlMetric.QUERY_RESPONSE_SIZE.key)
.description("Tracks graphql query response size.")
.baseUnit("bytes")
.tags(tags)
.publishPercentiles(0.90, 0.95, 0.99)
.register(registrySupplier.get())

launch {
val gqlDataSize: Deferred<Int> = async { MeasurementUtils.serializeAndMeasureSize(executionResult.getData<Map<String, *>>()) }
val gqlErrorsSize: Deferred<Int> = async { MeasurementUtils.serializeAndMeasureSize(executionResult.errors) }
val gqlExtensionsSize: Deferred<Int> = async { MeasurementUtils.serializeAndMeasureSize(executionResult.extensions) }

val totalSize = gqlDataSize.await() + gqlErrorsSize.await() + gqlExtensionsSize.await()
responseSizeMeter.record(totalSize.toDouble())
}
}

class MetricsInstrumentationState(
private val registry: MeterRegistry,
private val limitedTagMetricResolver: LimitedTagMetricResolver
Expand Down Expand Up @@ -382,4 +459,13 @@ class DgsGraphQLMetricsInstrumentation(

internal data class ErrorTagValues(val path: String, val type: String, val detail: String)
}

internal object MeasurementUtils {
suspend inline fun <reified T> serializeAndMeasureSize(obj: T): Int = coroutineScope {
ByteArrayOutputStream().use { outputStream ->
mapper.writeValue(outputStream, obj)
outputStream.size()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters()

assertThat(meters).containsOnlyKeys("gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.query", "gql.resolver")

assertThat(meters["gql.query"]).isNotNull.hasSize(1)
assertThat(meters["gql.query"]?.first()?.id?.tags)
Expand Down Expand Up @@ -165,7 +165,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters()

assertThat(meters).containsOnlyKeys("gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.query", "gql.resolver")

assertThat(meters["gql.query"]).isNotNull.hasSize(1)
assertThat(meters["gql.query"]?.first()?.id?.tags)
Expand Down Expand Up @@ -214,7 +214,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters()

assertThat(meters).containsOnlyKeys("gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.query", "gql.resolver")

assertThat(meters["gql.query"]).isNotNull.hasSize(1)
assertThat(meters["gql.query"]?.first()?.id?.tags)
Expand Down Expand Up @@ -283,7 +283,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters()

assertThat(meters).containsOnlyKeys("gql.dataLoader", "gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.dataLoader", "gql.query", "gql.resolver")

assertThat(meters["gql.dataLoader"]).isNotNull.hasSize(2)
assertThat(meters["gql.dataLoader"]?.map { it.id.tags })
Expand Down Expand Up @@ -362,7 +362,7 @@ class MicrometerServletSmokeTest {
)
val meters = fetchMeters("gql.")

assertThat(meters).containsOnlyKeys("gql.error", "gql.query")
assertThat(meters).containsKeys("gql.error", "gql.query")

assertThat(meters["gql.error"]).isNotNull.hasSize(1)
assertThat((meters["gql.error"]?.first() as CumulativeCounter).count()).isEqualTo(1.0)
Expand Down Expand Up @@ -405,7 +405,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters()

assertThat(meters).containsOnlyKeys("gql.query")
assertThat(meters).containsKeys("gql.query")

assertThat(meters["gql.query"]).isNotNull.hasSize(1)
assertThat(meters["gql.query"]?.first()?.id?.tags)
Expand Down Expand Up @@ -450,7 +450,7 @@ class MicrometerServletSmokeTest {
)
val meters = fetchMeters("gql.")

assertThat(meters).containsOnlyKeys("gql.error", "gql.query")
assertThat(meters).containsKeys("gql.error", "gql.query")

assertThat(meters["gql.error"]).isNotNull.hasSize(1)
assertThat((meters["gql.error"]?.first() as CumulativeCounter).count()).isEqualTo(1.0)
Expand Down Expand Up @@ -510,7 +510,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters("gql.")

assertThat(meters).containsOnlyKeys("gql.error", "gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.error", "gql.query", "gql.resolver")

logMeters(meters["gql.error"])
assertThat(meters["gql.error"]).isNotNull.hasSizeGreaterThanOrEqualTo(1)
Expand Down Expand Up @@ -583,7 +583,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters("gql.")

assertThat(meters).containsOnlyKeys("gql.error", "gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.error", "gql.query", "gql.resolver")

assertThat(meters["gql.error"]).isNotNull.hasSizeGreaterThanOrEqualTo(1)
assertThat((meters["gql.error"]?.first() as CumulativeCounter).count()).isEqualTo(1.0)
Expand Down Expand Up @@ -655,7 +655,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters()

assertThat(meters).containsOnlyKeys("gql.error", "gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.error", "gql.query", "gql.resolver")

assertThat(meters["gql.error"]).isNotNull.hasSizeGreaterThanOrEqualTo(1)
assertThat((meters["gql.error"]?.first() as CumulativeCounter).count()).isEqualTo(1.0)
Expand Down Expand Up @@ -723,7 +723,7 @@ class MicrometerServletSmokeTest {

val meters = fetchMeters()

assertThat(meters).containsOnlyKeys("gql.error", "gql.query", "gql.resolver")
assertThat(meters).containsKeys("gql.error", "gql.query", "gql.resolver")

assertThat(meters["gql.query"]).hasSizeGreaterThanOrEqualTo(1)
assertThat(meters["gql.error"]).hasSizeGreaterThanOrEqualTo(3)
Expand Down Expand Up @@ -767,6 +767,102 @@ class MicrometerServletSmokeTest {
)
}

@Test
fun `Query input size metrics for a successful graphql request`() {
mvc.perform(
MockMvcRequestBuilders
.post("/graphql")
.contentType(MediaType.APPLICATION_JSON)
.content("""{ "query": "query my_op_1{ping}" }""")
).andExpect(status().isOk)
.andExpect(content().json("""{"data":{"ping":"pong"}}""", false))

val meters = fetchMeters()

// Check metrics are present.
assertThat(meters).containsKeys(
"gql.query.request.size", "gql.query.request.size.percentile",
"gql.query.response.size", "gql.query.response.size.percentile"
)

// Check expected percentiles: .90, .95, .99
assertThat(meters["gql.query.request.size.percentile"]).isNotNull
assertThat(meters["gql.query.response.size.percentile"]).isNotNull

// Check metric name and expected tags.
assertThat(meters["gql.query.request.size"]).isNotNull
assertThat(meters["gql.query.request.size"]?.first()?.id?.tags)
.containsAll(
Tags.of("gql.operation", "QUERY")
.and("gql.operation.name", "my_op_1")
.and("gql.query.complexity", "5")
.and("gql.query.sig.hash", MOCKED_QUERY_SIGNATURE.hash)
)

assertThat(meters["gql.query.response.size"]).isNotNull
assertThat(meters["gql.query.response.size"]?.first()?.id?.tags)
.containsAll(
Tags.of("outcome", "success")
.and("gql.operation", "QUERY")
.and("gql.operation.name", "my_op_1")
.and("gql.query.complexity", "5")
.and("gql.query.sig.hash", MOCKED_QUERY_SIGNATURE.hash)
)
}

@Test
fun `Query input size metrics for a unsuccessful graphql request`() {
mvc.perform(
MockMvcRequestBuilders
.post("/graphql")
.contentType(MediaType.APPLICATION_JSON)
.content("""{ "query": "{triggerBadRequestFailure}" }""")
).andExpect(status().isOk)
.andExpect(
content().json(
"""
|{
| "errors":[
| {"message":"Exception triggered.",
| "locations":[],"path":["triggerBadRequestFailure"],
| "extensions":{"errorType":"BAD_REQUEST"}}
| ],
| "data":{"triggerBadRequestFailure":null}
|}
""".trimMargin(),
false
)
)

val meters = fetchMeters()

// Check metrics are present.
assertThat(meters).containsKeys(
"gql.query.request.size", "gql.query.request.size.percentile",
"gql.query.response.size", "gql.query.response.size.percentile"
)

// Check metric name and expected tags.
assertThat(meters["gql.query.request.size"]).isNotNull
assertThat(meters["gql.query.request.size"]?.first()?.id?.tags)
.containsAll(
Tags.of("gql.operation", "QUERY")
.and("gql.operation.name", "anonymous")
.and("gql.query.complexity", "5")
.and("gql.query.sig.hash", MOCKED_QUERY_SIGNATURE.hash)
)

assertThat(meters["gql.query.response.size"]).isNotNull
assertThat(meters["gql.query.response.size"]?.first()?.id?.tags)
.containsAll(
Tags.of("outcome", "failure")
.and("gql.operation", "QUERY")
.and("gql.operation.name", "anonymous")
.and("gql.query.complexity", "5")
.and("gql.query.sig.hash", MOCKED_QUERY_SIGNATURE.hash)
)
}

private fun fetchMeters(prefix: String = "gql."): Map<String, List<Meter>> {
return meterRegistry.meters
.asSequence()
Expand Down

0 comments on commit 1291438

Please sign in to comment.