Skip to content

Commit a42ea7a

Browse files
authored
Fix ApiCallDuration so that it measures the whole API call (#7338)
* Fix `ApiCallDuration` so that it measures the whole API call * Cleanups * PR Feedback * Minor cleanups * Cleanups + fix apiCallTimeout docs.
1 parent f8cacce commit a42ea7a

19 files changed

Lines changed: 750 additions & 189 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "bugfix",
3+
"category": "AWS SDK for Java v2",
4+
"contributor": "",
5+
"description": "Fix `ApiCallDuration` so that it measures the whole API call. It previously started after marshalling had already completed, and on asynchronous clients it also started after endpoint resolution, auth scheme resolution, request compression and checksum computation, and so understated the reported duration. The metric is now measured identically for synchronous and asynchronous clients and matches the documented formula. Reported `ApiCallDuration` values will increase but do not reflect changes in actual round-trip latency."
6+
}

core/sdk-core/pom.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,10 @@
255255
<artifactId>japicmp-maven-plugin</artifactId>
256256
<configuration>
257257
<parameter>
258-
<excludes>
258+
<!-- combine.children="append" keeps the root pom's excludes, notably the *.internal.* wildcard.
259+
Without it Maven replaces the parent's list with this one, and deleting any internal
260+
sdk-core class fails the japicmp gate. -->
261+
<excludes combine.children="append">
259262
<exclude>software.amazon.awssdk.core.spi.identity.AuthSchemeOptionsResolver#resolve(software.amazon.awssdk.core.SdkRequest)</exclude>
260263
</excludes>
261264
</parameter>

core/sdk-core/src/main/java/software/amazon/awssdk/core/client/config/ClientOverrideConfiguration.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ public Optional<ScheduledExecutorService> scheduledExecutorService() {
288288
* execution except for marshalling. This includes request handler execution, all HTTP requests including retries,
289289
* unmarshalling, etc. This value should always be positive, if present.
290290
*
291+
* <p>Because this window is narrower than the API call as a whole, the reported
292+
* {@link software.amazon.awssdk.core.metrics.CoreMetric#API_CALL_DURATION} metric can exceed this timeout.
293+
*
291294
* <p>The api call timeout feature doesn't have strict guarantees on how quickly a request is aborted when the
292295
* timeout is breached. The typical case aborts the request within a few milliseconds but there may occasionally be
293296
* requests that don't get aborted until several seconds after the timer has been breached. Because of this, the client
@@ -613,6 +616,9 @@ default Builder retryStrategy(Consumer<RetryStrategy.Builder<?, ?>> configurator
613616
* entire client execution except for marshalling. This includes request handler execution, all HTTP requests including
614617
* retries, unmarshalling, etc. This value should always be positive, if present.
615618
*
619+
* <p>Because this window is narrower than the API call as a whole, the reported
620+
* {@link software.amazon.awssdk.core.metrics.CoreMetric#API_CALL_DURATION} metric can exceed this timeout.
621+
*
616622
* <p>The api call timeout feature doesn't have strict guarantees on how quickly a request is aborted when the
617623
* timeout is breached. The typical case aborts the request within a few milliseconds but there may occasionally be
618624
* requests that don't get aborted until several seconds after the timer has been breached. Because of this, the client

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/handler/BaseAsyncClientHandler.java

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import static software.amazon.awssdk.utils.FunctionalUtils.runAndLogError;
1919

20+
import java.time.Duration;
2021
import java.util.Optional;
2122
import java.util.concurrent.CompletableFuture;
2223
import java.util.function.Function;
@@ -50,6 +51,7 @@
5051
import software.amazon.awssdk.http.SdkHttpFullRequest;
5152
import software.amazon.awssdk.http.SdkHttpFullResponse;
5253
import software.amazon.awssdk.metrics.MetricCollector;
54+
import software.amazon.awssdk.metrics.NoOpMetricCollector;
5355
import software.amazon.awssdk.utils.CompletableFutureUtils;
5456
import software.amazon.awssdk.utils.Logger;
5557

@@ -70,7 +72,7 @@ protected BaseAsyncClientHandler(SdkClientConfiguration clientConfiguration,
7072
public <InputT extends SdkRequest, OutputT extends SdkResponse> CompletableFuture<OutputT> execute(
7173
ClientExecutionParams<InputT, OutputT> executionParams) {
7274

73-
return measureApiCallSuccess(executionParams, () -> {
75+
return measureApiCall(executionParams, () -> {
7476
// Running beforeExecution interceptors and modifyRequest interceptors.
7577
ExecutionContext executionContext = invokeInterceptorsAndCreateExecutionContext(executionParams);
7678

@@ -86,7 +88,7 @@ public <InputT extends SdkRequest, OutputT extends SdkResponse, ReturnT> Complet
8688
ClientExecutionParams<InputT, OutputT> executionParams,
8789
AsyncResponseTransformer<OutputT, ReturnT> asyncResponseTransformer) {
8890

89-
return measureApiCallSuccess(executionParams, () -> {
91+
return measureApiCall(executionParams, () -> {
9092
if (executionParams.getCombinedResponseHandler() != null) {
9193
// There is no support for catching errors in a body for streaming responses. Our codegen must never
9294
// attempt to do this.
@@ -232,7 +234,10 @@ private <InputT extends SdkRequest, OutputT extends SdkResponse, ReturnT> Comple
232234
new AsyncAfterTransmissionInterceptorCallingResponseHandler<>(asyncResponseHandler,
233235
executionContext));
234236

237+
// Captured because the requestBody branch above may reassign 'marshalled', leaving it not effectively final.
238+
SdkHttpFullRequest requestForMetrics = marshalled;
235239
CompletableFuture<ReturnT> exceptionTranslatedFuture = invokeFuture.handle((resp, err) -> {
240+
reportServiceEndpointMetric(executionContext, requestForMetrics);
236241
if (err != null) {
237242
throw ThrowableUtils.failure(err);
238243
}
@@ -288,27 +293,49 @@ private <InputT extends SdkRequest, OutputT> CompletableFuture<OutputT> invoke(
288293
.execute(responseHandler);
289294
}
290295

291-
private <T> CompletableFuture<T> measureApiCallSuccess(ClientExecutionParams<?, ?> executionParams,
292-
Supplier<CompletableFuture<T>> apiCall) {
296+
/**
297+
* Measure {@link CoreMetric#API_CALL_DURATION} and report {@link CoreMetric#API_CALL_SUCCESSFUL} for the whole API
298+
* call.
299+
*
300+
* <p>The window deliberately encloses everything the SDK does for the call, marshalling included, and closes when
301+
* the returned future completes. Measuring inside the request pipeline is not an option: the pipeline's input is the
302+
* already-marshalled request, so no arrangement of pipeline stages can enclose marshalling. Measuring here also
303+
* keeps the window identical to the synchronous client's.
304+
*/
305+
private <T> CompletableFuture<T> measureApiCall(ClientExecutionParams<?, ?> executionParams,
306+
Supplier<CompletableFuture<T>> apiCall) {
307+
MetricCollector metricCollector = executionParams.getMetricCollector();
308+
if (metricCollector == null || metricCollector instanceof NoOpMetricCollector) {
309+
// Nothing will consume these metrics, so don't pay for the clock reads or the extra future. A null collector
310+
// is treated the same as NoOp: when the params carry none, the collector that AwsExecutionContextBuilder
311+
// substitutes into the ExecutionContext is never handed to a publisher, so anything reported to it is
312+
// discarded.
313+
try {
314+
return apiCall.get();
315+
} catch (Exception e) {
316+
return CompletableFutureUtils.failedFuture(e);
317+
}
318+
}
319+
320+
long callStart = System.nanoTime();
293321
try {
294322
CompletableFuture<T> apiCallResult = apiCall.get();
295323
CompletableFuture<T> outputFuture =
296-
apiCallResult.whenComplete((r, t) -> reportApiCallSuccess(executionParams, t == null));
324+
apiCallResult.whenComplete((r, t) -> reportApiCallMetrics(metricCollector, callStart, t == null));
297325

298326
// Preserve cancellations on the output future, by passing cancellations of the output future to the api call future.
299327
CompletableFutureUtils.forwardExceptionTo(outputFuture, apiCallResult);
300328

301329
return outputFuture;
302330
} catch (Exception e) {
303-
reportApiCallSuccess(executionParams, false);
331+
reportApiCallMetrics(metricCollector, callStart, false);
304332
return CompletableFutureUtils.failedFuture(e);
305333
}
306334
}
307335

308-
private void reportApiCallSuccess(ClientExecutionParams<?, ?> executionParams, boolean value) {
309-
MetricCollector metricCollector = executionParams.getMetricCollector();
310-
if (metricCollector != null) {
311-
metricCollector.reportMetric(CoreMetric.API_CALL_SUCCESSFUL, value);
312-
}
336+
private void reportApiCallMetrics(MetricCollector metricCollector, long callStartNanoTime, boolean successful) {
337+
long durationNanos = System.nanoTime() - callStartNanoTime;
338+
metricCollector.reportMetric(CoreMetric.API_CALL_SUCCESSFUL, successful);
339+
metricCollector.reportMetric(CoreMetric.API_CALL_DURATION, Duration.ofNanos(durationNanos));
313340
}
314341
}

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/handler/BaseClientHandler.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ static <InputT extends SdkRequest, OutputT> InterceptorContext finalizeSdkHttpFu
9090
return runModifyHttpRequestAndHttpContentInterceptors(executionContext);
9191
}
9292

93+
/**
94+
* Report the {@link CoreMetric#SERVICE_ENDPOINT} metric for a completed request execution.
95+
*
96+
* <p>This must run after the request pipeline, so that {@code EndpointResolutionStage} and the signer have applied
97+
* their changes to the HTTP request held by the interceptor context. {@code fallbackRequest} is used only when the
98+
* interceptor context does not hold a full request, which can happen when the execution failed before the pipeline
99+
* updated it.
100+
*/
101+
static void reportServiceEndpointMetric(ExecutionContext executionContext, SdkHttpFullRequest fallbackRequest) {
102+
SdkHttpRequest finalRequest = executionContext.interceptorContext().httpRequest();
103+
MetricUtils.collectServiceEndpointMetrics(executionContext.metricCollector(),
104+
finalRequest instanceof SdkHttpFullRequest
105+
? (SdkHttpFullRequest) finalRequest
106+
: fallbackRequest);
107+
}
108+
93109
private static void runBeforeMarshallingInterceptors(ExecutionContext executionContext) {
94110
executionContext.interceptorChain().beforeMarshalling(executionContext.interceptorContext(),
95111
executionContext.executionAttributes());

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/handler/BaseSyncClientHandler.java

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
package software.amazon.awssdk.core.internal.handler;
1717

18+
import java.time.Duration;
1819
import java.util.Optional;
1920
import java.util.function.Supplier;
2021
import software.amazon.awssdk.annotations.SdkInternalApi;
@@ -41,6 +42,7 @@
4142
import software.amazon.awssdk.http.SdkHttpFullRequest;
4243
import software.amazon.awssdk.http.SdkHttpFullResponse;
4344
import software.amazon.awssdk.metrics.MetricCollector;
45+
import software.amazon.awssdk.metrics.NoOpMetricCollector;
4446

4547
@SdkInternalApi
4648
public abstract class BaseSyncClientHandler extends BaseClientHandler implements SyncClientHandler {
@@ -57,7 +59,7 @@ public <InputT extends SdkRequest, OutputT extends SdkResponse, ReturnT> ReturnT
5759
ClientExecutionParams<InputT, OutputT> executionParams,
5860
ResponseTransformer<OutputT, ReturnT> responseTransformer) {
5961

60-
return measureApiCallSuccess(executionParams, () -> {
62+
return measureApiCall(executionParams, () -> {
6163
// Running beforeExecution interceptors and modifyRequest interceptors.
6264
ExecutionContext executionContext = invokeInterceptorsAndCreateExecutionContext(executionParams);
6365

@@ -71,7 +73,7 @@ public <InputT extends SdkRequest, OutputT extends SdkResponse, ReturnT> ReturnT
7173
public <InputT extends SdkRequest, OutputT extends SdkResponse> OutputT execute(
7274
ClientExecutionParams<InputT, OutputT> executionParams) {
7375

74-
return measureApiCallSuccess(executionParams, () -> {
76+
return measureApiCall(executionParams, () -> {
7577
// Running beforeExecution interceptors and modifyRequest interceptors.
7678
ExecutionContext executionContext = invokeInterceptorsAndCreateExecutionContext(executionParams);
7779

@@ -170,28 +172,44 @@ private <InputT extends SdkRequest, OutputT, ReturnT> ReturnT doExecute(
170172
}
171173

172174
SdkClientConfiguration clientConfiguration = resolveRequestConfiguration(executionParams);
173-
return invoke(clientConfiguration,
174-
marshalled,
175-
inputT,
176-
executionContext,
177-
responseHandler);
175+
try {
176+
return invoke(clientConfiguration,
177+
marshalled,
178+
inputT,
179+
executionContext,
180+
responseHandler);
181+
} finally {
182+
reportServiceEndpointMetric(executionContext, marshalled);
183+
}
178184
}
179185

180-
private <T> T measureApiCallSuccess(ClientExecutionParams<?, ?> executionParams, Supplier<T> thingToMeasureSuccessOf) {
186+
/**
187+
* Measure {@link CoreMetric#API_CALL_DURATION} and report {@link CoreMetric#API_CALL_SUCCESSFUL} for the whole API
188+
* call.
189+
*
190+
* <p>The window deliberately encloses everything the SDK does for the call, marshalling included. Measuring inside
191+
* the request pipeline is not an option: the pipeline's input is the already-marshalled request, so no arrangement
192+
* of pipeline stages can enclose marshalling.
193+
*/
194+
private <T> T measureApiCall(ClientExecutionParams<?, ?> executionParams, Supplier<T> apiCall) {
195+
MetricCollector metricCollector = executionParams.getMetricCollector();
196+
if (metricCollector == null || metricCollector instanceof NoOpMetricCollector) {
197+
// Nothing will consume these metrics, so don't pay for the clock reads. A null collector is treated the same
198+
// as NoOp: when the params carry none, the collector that AwsExecutionContextBuilder substitutes into the
199+
// ExecutionContext is never handed to a publisher, so anything reported to it is discarded.
200+
return apiCall.get();
201+
}
202+
203+
long callStart = System.nanoTime();
181204
try {
182-
T result = thingToMeasureSuccessOf.get();
183-
reportApiCallSuccess(executionParams, true);
205+
T result = apiCall.get();
206+
metricCollector.reportMetric(CoreMetric.API_CALL_SUCCESSFUL, true);
184207
return result;
185208
} catch (Exception e) {
186-
reportApiCallSuccess(executionParams, false);
209+
metricCollector.reportMetric(CoreMetric.API_CALL_SUCCESSFUL, false);
187210
throw e;
188-
}
189-
}
190-
191-
private void reportApiCallSuccess(ClientExecutionParams<?, ?> executionParams, boolean value) {
192-
MetricCollector metricCollector = executionParams.getMetricCollector();
193-
if (metricCollector != null) {
194-
metricCollector.reportMetric(CoreMetric.API_CALL_SUCCESSFUL, value);
211+
} finally {
212+
metricCollector.reportMetric(CoreMetric.API_CALL_DURATION, Duration.ofNanos(System.nanoTime() - callStart));
195213
}
196214
}
197215

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/AmazonAsyncHttpClient.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApplyTransactionIdStage;
3434
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApplyUserAgentStage;
3535
import software.amazon.awssdk.core.internal.http.pipeline.stages.AsyncApiCallAttemptMetricCollectionStage;
36-
import software.amazon.awssdk.core.internal.http.pipeline.stages.AsyncApiCallMetricCollectionStage;
3736
import software.amazon.awssdk.core.internal.http.pipeline.stages.AsyncApiCallTimeoutTrackingStage;
3837
import software.amazon.awssdk.core.internal.http.pipeline.stages.AsyncBeforeTransmissionExecutionInterceptorsStage;
3938
import software.amazon.awssdk.core.internal.http.pipeline.stages.AsyncExecutionFailureExceptionReportingStage;
@@ -214,8 +213,8 @@ public <OutputT> CompletableFuture<OutputT> execute(
214213
.then(async(() -> new UnwrapResponseContainer<>()))
215214
.then(async(() -> new AfterExecutionInterceptorsStage<>()))
216215
.wrappedWith(AsyncExecutionFailureExceptionReportingStage::new)
217-
.wrappedWith(AsyncApiCallTimeoutTrackingStage::new)
218-
.wrappedWith(AsyncApiCallMetricCollectionStage::new)::build)::build)
216+
// Note: API_CALL_DURATION is measured by BaseAsyncClientHandler
217+
.wrappedWith(AsyncApiCallTimeoutTrackingStage::new)::build)::build)
219218
.build(httpClientDependencies)
220219
.execute(request, createRequestExecutionDependencies());
221220
} catch (RuntimeException e) {

core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/AmazonSyncHttpClient.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
import software.amazon.awssdk.core.internal.http.pipeline.stages.AfterTransmissionExecutionInterceptorsStage;
3131
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApiCallAttemptMetricCollectionStage;
3232
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApiCallAttemptTimeoutTrackingStage;
33-
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApiCallMetricCollectionStage;
3433
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApiCallTimeoutTrackingStage;
3534
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApplyTransactionIdStage;
3635
import software.amazon.awssdk.core.internal.http.pipeline.stages.ApplyUserAgentStage;
@@ -206,7 +205,7 @@ public <OutputT> OutputT execute(HttpResponseHandler<Response<OutputT>> response
206205
.wrappedWith(RetryableStage::new)::build)
207206
.wrappedWith(StreamManagingStage::new)
208207
.wrappedWith(ApiCallTimeoutTrackingStage::new)::build)
209-
.wrappedWith((deps, wrapped) -> new ApiCallMetricCollectionStage<>(wrapped))
208+
// Note: API_CALL_DURATION is measured by BaseSyncClientHandler
210209
.then(() -> new UnwrapResponseContainer<>())
211210
.then(() -> new AfterExecutionInterceptorsStage<>())
212211
.wrappedWith(ExecutionFailureExceptionReportingStage::new)

0 commit comments

Comments
 (0)