Skip to content

Commit 23d928f

Browse files
authored
fix: Pass HttpResponse from HttpClientResponseException to OpenTelemetry client instrumenter (#710)
* Added test to demonstrate missing http error status_code in client spans * Pass the HttpResponse to OTel instrumentation when client call throws HttpClientResponseException
1 parent 2f8805e commit 23d928f

File tree

2 files changed

+50
-1
lines changed

2 files changed

+50
-1
lines changed

tracing-opentelemetry-http/src/main/java/io/micronaut/tracing/opentelemetry/instrument/http/client/OpenTelemetryClientFilter.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.micronaut.core.annotation.Nullable;
2121
import io.micronaut.core.propagation.PropagatedContext;
2222
import io.micronaut.http.HttpResponse;
23+
import io.micronaut.http.HttpResponseProvider;
2324
import io.micronaut.http.MutableHttpRequest;
2425
import io.micronaut.http.annotation.Filter;
2526
import io.micronaut.http.filter.ClientFilterChain;
@@ -38,6 +39,8 @@
3839
import org.reactivestreams.Publisher;
3940
import reactor.core.publisher.Mono;
4041

42+
import java.util.Optional;
43+
4144
import static io.micronaut.http.HttpAttributes.INVOCATION_CONTEXT;
4245
import static io.micronaut.tracing.opentelemetry.instrument.http.client.OpenTelemetryClientFilter.CLIENT_PATH;
4346

@@ -93,13 +96,21 @@ public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> reque
9396
Span span = Span.fromContext(context);
9497
span.recordException(throwable);
9598
span.setStatus(StatusCode.ERROR);
96-
instrumenter.end(context, request, null, throwable);
99+
HttpResponse<?> response = findResponseInThrowable(throwable).orElse(null);
100+
instrumenter.end(context, request, response, throwable);
97101
});
98102

99103
}
100104
}
101105
}
102106

107+
private Optional<HttpResponse<?>> findResponseInThrowable(@Nullable Throwable throwable) {
108+
if (throwable instanceof HttpResponseProvider httpResponseProvider) {
109+
return Optional.ofNullable(httpResponseProvider.getResponse());
110+
}
111+
return Optional.empty();
112+
}
113+
103114
private void handleContinueSpan(MutableHttpRequest<?> request) {
104115
Object invocationContext = request.getAttribute(INVOCATION_CONTEXT).orElse(null);
105116
if (invocationContext instanceof MethodInvocationContext<?, ?> context) {

tracing-opentelemetry-http/src/test/groovy/io/micronaut/tracing/opentelemetry/instrument/http/OpenTelemetryHttpSpec.groovy

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ class OpenTelemetryHttpSpec extends Specification {
108108
assert serverSpans.every {it.attributes.stream().any { it.get(HttpAttributes.HTTP_REQUEST_METHOD) }}
109109
assert !hasRoute || serverSpans.every {it.attributes.stream().any { it.get(HttpAttributes.HTTP_ROUTE) }}
110110
assert serverSpans.every {it.attributes.stream().any {x -> Optional.ofNullable(x.get(HttpAttributes.HTTP_RESPONSE_STATUS_CODE)).map { it.intValue() == httpStatus.code }.orElse(false) }}
111+
def clientSpans = exporter.finishedSpanItems.findAll { it -> it.kind == SpanKind.CLIENT }
112+
assert clientSpans.every {it.attributes.stream().any { it.get(HttpAttributes.HTTP_REQUEST_METHOD) }}
113+
assert clientSpans.every {it.attributes.stream().any {x -> Optional.ofNullable(x.get(HttpAttributes.HTTP_RESPONSE_STATUS_CODE)).map { it.intValue() == httpStatus.code }.orElse(false) }}
111114
}
112115

113116
void 'test map WithSpan annotation'() {
@@ -232,6 +235,34 @@ class OpenTelemetryHttpSpec extends Specification {
232235
'/error/completionStageErrorContinueSpan' | 1 | 'inside normal method continueSpan'
233236
}
234237

238+
void 'test client error #desc, path=/error/#variant'() {
239+
def errorClient = context.getBean(ErrorClient)
240+
241+
when:
242+
String responseBody = errorClient.get(variant)
243+
244+
then:
245+
def e = thrown(HttpClientResponseException)
246+
e.message == "Internal Server Error"
247+
conditions.eventually {
248+
hasSpans(hasInternalSpan ? 1 : 0, 1, 1)
249+
exporter.finishedSpanItems.events.any { it.size() > 0 && it.get(0).name == "exception" }
250+
exporter.finishedSpanItems.stream().allMatch(span -> span.status.statusCode == StatusCode.ERROR)
251+
hasHttpSemanticAttributes(e.status)
252+
}
253+
cleanup:
254+
exporter.reset()
255+
where:
256+
variant | hasInternalSpan | desc
257+
'publisher' | true | 'inside publisher'
258+
'publisherErrorContinueSpan' | false | 'inside continueSpan publisher'
259+
'mono' | true | 'propagated through publisher'
260+
'sync' | true | 'inside normal function'
261+
'completionStage' | true | 'inside completionStage'
262+
'completionStagePropagation' | true | 'propagated through completionStage'
263+
'completionStageErrorContinueSpan' | false | 'inside normal method continueSpan'
264+
}
265+
235266
void 'client with tracing annotations'() {
236267
def warehouseClient = embeddedServer.applicationContext.getBean(WarehouseClient)
237268
def serverSpanCount = 2
@@ -641,6 +672,13 @@ class OpenTelemetryHttpSpec extends Specification {
641672
}
642673
}
643674

675+
@Client("/error")
676+
static interface ErrorClient {
677+
678+
@Get("/{variant}")
679+
String get(@PathVariable String variant)
680+
}
681+
644682
@Controller('/exclude')
645683
static class ExcludeController {
646684

0 commit comments

Comments
 (0)