Skip to content

Commit 96b7e57

Browse files
committed
Convert webclient OTel metrics sem. conv. to OTel API instead of Helidon metrics API
1 parent 2a8a808 commit 96b7e57

6 files changed

Lines changed: 218 additions & 99 deletions

File tree

webclient/telemetry/metrics/pom.xml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,17 @@
3737
<artifactId>helidon-common-features-api</artifactId>
3838
</dependency>
3939
<dependency>
40-
<groupId>io.helidon.metrics</groupId>
41-
<artifactId>helidon-metrics-api</artifactId>
40+
<groupId>io.helidon.telemetry</groupId>
41+
<artifactId>helidon-telemetry-opentelemetry-config</artifactId>
4242
</dependency>
4343
<dependency>
44-
<groupId>io.helidon.metrics.providers</groupId>
45-
<artifactId>helidon-metrics-providers-micrometer</artifactId>
46-
<scope>runtime</scope>
44+
<groupId>io.opentelemetry</groupId>
45+
<artifactId>opentelemetry-api</artifactId>
46+
</dependency>
47+
<dependency>
48+
<groupId>io.opentelemetry</groupId>
49+
<artifactId>opentelemetry-exporter-logging-otlp</artifactId>
50+
<scope>test</scope>
4751
</dependency>
4852
<dependency>
4953
<groupId>org.junit.jupiter</groupId>
@@ -65,7 +69,6 @@
6569
<artifactId>helidon-webserver-testing-junit5</artifactId>
6670
<scope>test</scope>
6771
</dependency>
68-
6972
</dependencies>
7073

7174
<build>

webclient/telemetry/metrics/src/main/java/io/helidon/webclient/telemetry/metrics/WebClientTelemetryMetrics.java

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,33 @@
1717
package io.helidon.webclient.telemetry.metrics;
1818

1919
import java.util.List;
20-
import java.util.concurrent.TimeUnit;
2120

2221
import io.helidon.common.LazyValue;
2322
import io.helidon.config.Config;
2423
import io.helidon.http.Status;
25-
import io.helidon.metrics.api.MeterRegistry;
26-
import io.helidon.metrics.api.Tag;
27-
import io.helidon.metrics.api.Timer;
2824
import io.helidon.service.registry.Services;
25+
import io.helidon.telemetry.otelconfig.HelidonOpenTelemetry;
2926
import io.helidon.webclient.api.WebClientServiceRequest;
3027
import io.helidon.webclient.api.WebClientServiceResponse;
3128
import io.helidon.webclient.spi.WebClientService;
3229

30+
import io.opentelemetry.api.OpenTelemetry;
31+
import io.opentelemetry.api.common.Attributes;
32+
import io.opentelemetry.api.metrics.DoubleHistogram;
33+
3334
/**
3435
* Webclient service for providing metrics which comply with the OpenTelemetry semantic conventions for
3536
* client metrics.
3637
*/
3738
public class WebClientTelemetryMetrics implements WebClientService {
3839

40+
/*
41+
Bucket boundaries as recommended by the OpenTelemetry semantic conventions.
42+
https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration
43+
*/
44+
private static final List<Double> BUCKET_BOUNDARIES =
45+
List.of(0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0);
46+
3947
static final String REQUEST_DURATION = "http.client.request.duration";
4048

4149
static final String HTTP_REQUEST_METHOD = "http.client.request.method";
@@ -44,8 +52,10 @@ public class WebClientTelemetryMetrics implements WebClientService {
4452
static final String ERROR_TYPE = "error.type";
4553
static final String HTTP_RESPONSE_STATUS_CODE = "http.response.status.code";
4654
static final String URL_SCHEME = "url.scheme";
55+
static final String URL_TEMPLATE = "url.template";
4756

48-
private final LazyValue<MeterRegistry> meterRegistry = LazyValue.create(() -> Services.get(MeterRegistry.class));
57+
private final LazyValue<DoubleHistogram> outboundHttpRequestDuration =
58+
LazyValue.create(WebClientTelemetryMetrics::createHistogram);
4959

5060
private WebClientTelemetryMetrics() {
5161
}
@@ -71,30 +81,46 @@ public static WebClientTelemetryMetrics create(Config config) {
7181

7282
@Override
7383
public WebClientServiceResponse handle(Chain chain, WebClientServiceRequest clientRequest) {
74-
long start = System.nanoTime();
84+
long startTime = System.nanoTime();
7585

7686
String errorType = "";
77-
String statusCodeTagValue = "";
87+
int statusCodeTagValue = 0;
7888
try {
7989
var response = chain.proceed(clientRequest);
80-
statusCodeTagValue = response.status().codeText();
81-
errorType = response.status().family() == Status.Family.SUCCESSFUL ? "" : statusCodeTagValue;
90+
statusCodeTagValue = response.status().code();
91+
errorType = response.status().family() == Status.Family.SUCCESSFUL ? "" : Integer.toString(statusCodeTagValue);
8292
return response;
8393
} catch (Exception ex) {
8494
errorType = ex.getClass().getSimpleName();
8595
throw ex;
8696
} finally {
87-
var tags = List.of(
88-
Tag.create(HTTP_REQUEST_METHOD, clientRequest.method().text()),
89-
Tag.create(SERVER_ADDRESS, clientRequest.uri().host()),
90-
Tag.create(SERVER_PORT, Integer.toString(clientRequest.uri().port())),
91-
Tag.create(ERROR_TYPE, errorType),
92-
Tag.create(HTTP_RESPONSE_STATUS_CODE, statusCodeTagValue),
93-
Tag.create(URL_SCHEME, clientRequest.uri().scheme()));
94-
95-
var timer = meterRegistry.get().getOrCreate(Timer.builder(REQUEST_DURATION)
96-
.tags(tags));
97-
timer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
97+
long endTime = System.nanoTime();
98+
var attributes = Attributes.builder()
99+
.put(HTTP_REQUEST_METHOD, clientRequest.method().text())
100+
.put(SERVER_ADDRESS, clientRequest.uri().host())
101+
.put(SERVER_PORT, clientRequest.uri().port())
102+
.put(ERROR_TYPE, errorType)
103+
.put(HTTP_RESPONSE_STATUS_CODE, statusCodeTagValue)
104+
.put(URL_SCHEME, clientRequest.uri().scheme())
105+
.put(URL_TEMPLATE, clientRequest.uri().path().path())
106+
.build();
107+
108+
outboundHttpRequestDuration.get().record(endTime - startTime, attributes);
98109
}
99110
}
111+
112+
private static DoubleHistogram createHistogram() {
113+
var config = Services.get(Config.class);
114+
HelidonOpenTelemetry helidonOpenTelemetry = HelidonOpenTelemetry.builder()
115+
.config(config.get(HelidonOpenTelemetry.CONFIG_KEY))
116+
.build();
117+
return Services.get(OpenTelemetry.class)
118+
.getMeterProvider()
119+
.get(helidonOpenTelemetry.prototype().service())
120+
.histogramBuilder(REQUEST_DURATION)
121+
.setDescription("Outbound HTTP request duration")
122+
.setUnit("s") // seconds
123+
.setExplicitBucketBoundariesAdvice(BUCKET_BOUNDARIES)
124+
.build();
125+
}
100126
}

webclient/telemetry/metrics/src/main/java/module-info.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
requires static io.helidon.common.features.api;
3131

3232
requires io.helidon.config;
33-
requires io.helidon.metrics.api;
33+
requires io.helidon.telemetry.otelconfig;
3434
requires transitive io.helidon.webclient.api;
35+
requires io.opentelemetry.api;
3536

3637
exports io.helidon.webclient.telemetry.metrics;
3738
}

webclient/telemetry/metrics/src/test/java/io/helidon/webclient/telemetry/metrics/TestClientMetrics.java

Lines changed: 71 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -16,39 +16,36 @@
1616

1717
package io.helidon.webclient.telemetry.metrics;
1818

19-
import java.util.ArrayList;
2019
import java.util.List;
21-
import java.util.concurrent.TimeUnit;
20+
import java.util.logging.Logger;
21+
import java.util.regex.Pattern;
2222

2323
import io.helidon.common.media.type.MediaTypes;
24-
import io.helidon.http.Method;
25-
import io.helidon.metrics.api.MeterRegistry;
26-
import io.helidon.metrics.api.Timer;
2724
import io.helidon.webclient.api.WebClient;
2825
import io.helidon.webserver.WebServer;
2926
import io.helidon.webserver.http.HttpRouting;
3027
import io.helidon.webserver.testing.junit5.ServerTest;
3128
import io.helidon.webserver.testing.junit5.SetUpRoute;
3229

30+
import io.opentelemetry.exporter.logging.otlp.OtlpJsonLoggingMetricExporter;
31+
import org.hamcrest.FeatureMatcher;
32+
import org.hamcrest.Matcher;
3333
import org.junit.jupiter.api.Test;
3434

3535
import static org.hamcrest.MatcherAssert.assertThat;
3636
import static org.hamcrest.Matchers.allOf;
37+
import static org.hamcrest.Matchers.containsString;
3738
import static org.hamcrest.Matchers.equalTo;
38-
import static org.hamcrest.Matchers.greaterThan;
39-
import static org.hamcrest.Matchers.hasEntry;
40-
import static org.hamcrest.Matchers.hasSize;
4139
import static org.hamcrest.Matchers.is;
40+
import static org.hamcrest.Matchers.not;
4241

4342
@ServerTest
4443
class TestClientMetrics {
4544

4645
private final WebServer server;
47-
private final MeterRegistry meterRegistry;
4846

49-
TestClientMetrics(WebServer server, MeterRegistry meterRegistry) {
47+
TestClientMetrics(WebServer server) {
5048
this.server = server;
51-
this.meterRegistry = meterRegistry;
5249
}
5350

5451
@SetUpRoute
@@ -57,67 +54,69 @@ static void setup(HttpRouting.Builder builder) {
5754
}
5855

5956
@Test
60-
void testClientMetrics() {
61-
var client = WebClient.builder()
62-
.baseUri("http://localhost:" + server.port())
63-
.addService(WebClientTelemetryMetrics.create())
64-
.build();
65-
66-
var response = client.get("/greet")
67-
.accept(MediaTypes.TEXT_PLAIN)
68-
.request(String.class);
69-
70-
assertThat("Response status", response.status().code(), is(200));
71-
72-
List<Timer> timers = meterRegistry.meters(meter -> meter.id().name().equals(WebClientTelemetryMetrics.REQUEST_DURATION))
73-
.stream()
74-
.filter(m -> m instanceof Timer)
75-
.map(m -> (Timer) m)
76-
.toList();
77-
78-
assertThat("Timers", timers, hasSize(equalTo(1)));
79-
80-
Timer goodTimer = timers.getFirst();
81-
82-
assertThat("Timer count", goodTimer.count(), is(greaterThan(0L)));
83-
assertThat("Timer duration", goodTimer.totalTime(TimeUnit.NANOSECONDS), is(greaterThan(0D)));
84-
var tags = goodTimer.id().tagsMap();
85-
86-
assertThat("Timer tags", tags, allOf(
87-
hasEntry(WebClientTelemetryMetrics.HTTP_REQUEST_METHOD, Method.GET_NAME),
88-
hasEntry(WebClientTelemetryMetrics.SERVER_ADDRESS, "localhost"),
89-
hasEntry(WebClientTelemetryMetrics.SERVER_PORT, Integer.toString(server.port())),
90-
hasEntry(WebClientTelemetryMetrics.ERROR_TYPE, ""),
91-
hasEntry(WebClientTelemetryMetrics.HTTP_RESPONSE_STATUS_CODE, "200"),
92-
hasEntry(WebClientTelemetryMetrics.URL_SCHEME, "http")));
93-
94-
var response404 = client.get("/missing")
95-
.accept(MediaTypes.TEXT_PLAIN)
96-
.request(String.class);
97-
98-
assertThat("Expected failed response", response404.status().code(), is(404));
99-
100-
timers = new ArrayList<>(meterRegistry.meters(meter -> meter.id().name().equals(WebClientTelemetryMetrics.REQUEST_DURATION))
101-
.stream()
102-
.filter(m -> m instanceof Timer)
103-
.map(m -> (Timer) m)
104-
.toList());
105-
timers.remove(goodTimer);
106-
107-
assertThat("Bad timer count", timers, hasSize(1));
108-
109-
var badTimer = timers.getFirst();
110-
assertThat("Timer count", badTimer.count(), is(greaterThan(0L)));
111-
assertThat("Bad timer duration", badTimer.totalTime(TimeUnit.NANOSECONDS), is(greaterThan(0D)));
112-
113-
tags = badTimer.id().tagsMap();
114-
assertThat("Bad timer tags", tags, allOf(
115-
hasEntry(WebClientTelemetryMetrics.HTTP_REQUEST_METHOD, Method.GET_NAME),
116-
hasEntry(WebClientTelemetryMetrics.SERVER_ADDRESS, "localhost"),
117-
hasEntry(WebClientTelemetryMetrics.SERVER_PORT, Integer.toString(server.port())),
118-
hasEntry(WebClientTelemetryMetrics.ERROR_TYPE, "404"),
119-
hasEntry(WebClientTelemetryMetrics.HTTP_RESPONSE_STATUS_CODE, "404"),
120-
hasEntry(WebClientTelemetryMetrics.URL_SCHEME, "http")));
57+
void testClientMetrics() throws InterruptedException {
58+
try (TestLogHandler handler = TestLogHandler.create(Logger.getLogger(OtlpJsonLoggingMetricExporter.class.getName()))) {
12159

60+
var client = WebClient.builder()
61+
.baseUri("http://localhost:" + server.port())
62+
.addService(WebClientTelemetryMetrics.create())
63+
.build();
64+
65+
var response = client.get("/greet")
66+
.accept(MediaTypes.TEXT_PLAIN)
67+
.request(String.class);
68+
69+
assertThat("Response status", response.status().code(), is(200));
70+
71+
List<String> messages = handler.messages(1);
72+
73+
var patternText = ".*\"dataPoints\":.*\"count\":\"([^\"]+)\".*\"sum\":([^,]+),(.*)";
74+
75+
var jsonPattern = Pattern.compile(patternText);
76+
var matcher = jsonPattern.matcher(messages.getFirst());
77+
78+
assertThat("Matched log line", matcher, allOf(
79+
matches(true),
80+
hasGroupAsInteger(1, is(1)),
81+
hasGroupAsDouble(2, not(equalTo(0D))),
82+
hasGroupAsString(3, containsString("\"key\":\"" + WebClientTelemetryMetrics.URL_TEMPLATE + "\","
83+
+ "\"value\":{\"stringValue\":\"/greet\""))));
84+
85+
}
86+
}
87+
static Matcher<java.util.regex.Matcher> matches(boolean expected) {
88+
return new FeatureMatcher<>(is(expected), "matches text", "matches") {
89+
@Override
90+
protected Boolean featureValueOf(java.util.regex.Matcher actual) {
91+
return actual.matches();
92+
}
93+
};
94+
}
95+
96+
static Matcher<java.util.regex.Matcher> hasGroupAsString(int groupNumber, Matcher<String> matcher) {
97+
return new FeatureMatcher<>(matcher, "matches group " + groupNumber, "matches") {
98+
@Override
99+
protected String featureValueOf(java.util.regex.Matcher actual) {
100+
return actual.group(groupNumber);
101+
}
102+
};
103+
}
104+
105+
static Matcher<java.util.regex.Matcher> hasGroupAsInteger(int groupNumber, Matcher<Integer> matcher) {
106+
return new FeatureMatcher<>(matcher, "matches group " + groupNumber, "matches") {
107+
@Override
108+
protected Integer featureValueOf(java.util.regex.Matcher actual) {
109+
return Integer.parseInt(actual.group(groupNumber));
110+
}
111+
};
112+
}
113+
114+
static Matcher<java.util.regex.Matcher> hasGroupAsDouble(int groupNumber, Matcher<Double> matcher) {
115+
return new FeatureMatcher<>(matcher, "matches group " + groupNumber, "matches") {
116+
@Override
117+
protected Double featureValueOf(java.util.regex.Matcher actual) {
118+
return Double.parseDouble(actual.group(groupNumber));
119+
}
120+
};
122121
}
123122
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright (c) 2026 Oracle and/or its affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.helidon.webclient.telemetry.metrics;
18+
19+
import java.util.ArrayList;
20+
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.logging.Handler;
23+
import java.util.logging.LogRecord;
24+
import java.util.logging.Logger;
25+
26+
import io.helidon.common.testing.junit5.MatcherWithRetry;
27+
28+
import static org.hamcrest.Matchers.hasSize;
29+
30+
class TestLogHandler extends Handler implements AutoCloseable {
31+
32+
private final List<String> messages = Collections.synchronizedList(new ArrayList<>());
33+
private final Logger logger;
34+
35+
private TestLogHandler(Logger logger) {
36+
this.logger = logger;
37+
logger.addHandler(this);
38+
}
39+
40+
static TestLogHandler create(Logger logger) {
41+
return new TestLogHandler(logger);
42+
}
43+
44+
@Override
45+
public void publish(LogRecord record) {
46+
messages.add(record.getMessage());
47+
}
48+
49+
@Override
50+
public void flush() {
51+
}
52+
53+
@Override
54+
public void close() {
55+
logger.removeHandler(this);
56+
}
57+
58+
List<String> messages(int expectedCount) {
59+
try {
60+
return MatcherWithRetry.assertThatWithRetry("Expected messages", () -> List.copyOf(messages), hasSize(expectedCount));
61+
} finally {
62+
messages.clear();
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)