Skip to content

Commit 419ac3e

Browse files
authored
Add HTTP and Cache metrics (#643)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent 1510efd commit 419ac3e

6 files changed

Lines changed: 117 additions & 41 deletions

File tree

src/main/java/land/oras/auth/HttpClient.java

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
package land.oras.auth;
2222

2323
import io.micrometer.core.instrument.MeterRegistry;
24-
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
24+
import io.micrometer.core.instrument.Metrics;
25+
import io.micrometer.core.instrument.Timer;
2526
import java.io.FileNotFoundException;
2627
import java.io.InputStream;
2728
import java.net.*;
@@ -37,6 +38,7 @@
3738
import java.util.List;
3839
import java.util.Map;
3940
import java.util.Objects;
41+
import java.util.concurrent.TimeUnit;
4042
import java.util.function.Supplier;
4143
import java.util.regex.Matcher;
4244
import java.util.regex.Pattern;
@@ -108,7 +110,7 @@ private HttpClient() {
108110
this.skipTlsVerify = false;
109111
this.builder.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_NONE));
110112
this.setTimeout(60);
111-
this.meterRegistry = new SimpleMeterRegistry();
113+
this.meterRegistry = Metrics.globalRegistry;
112114
}
113115

114116
/**
@@ -533,7 +535,7 @@ private <T> ResponseWrapper<T> executeRequest(
533535

534536
HttpRequest request = builder.build();
535537
logRequest(request, body);
536-
HttpResponse<T> response = client.send(request, handler);
538+
HttpResponse<T> response = executeAndRecordRequest(request, handler);
537539

538540
// Follow redirect
539541
if (shouldRedirect(response)) {
@@ -565,6 +567,23 @@ private <T> ResponseWrapper<T> executeRequest(
565567
}
566568
}
567569

570+
private <T> HttpResponse<T> executeAndRecordRequest(HttpRequest request, HttpResponse.BodyHandler<T> handler)
571+
throws Exception {
572+
long start = System.nanoTime();
573+
HttpResponse<T> response = client.send(request, handler);
574+
long duration = System.nanoTime() - start;
575+
Timer.builder(Const.METRIC_HTTP_REQUESTS)
576+
.tag("method", request.method())
577+
.tag("host", request.uri().getHost())
578+
.tag("status", response != null ? String.valueOf(response.statusCode()) : "IO_ERROR")
579+
.register(meterRegistry)
580+
.record(duration, TimeUnit.NANOSECONDS);
581+
if (response == null) {
582+
throw new OrasException("No response received");
583+
}
584+
return response;
585+
}
586+
568587
private <T> String getLocationHeader(HttpResponse<T> response) {
569588
return response.headers()
570589
.firstValue("Location")
@@ -593,7 +612,7 @@ private <T> ResponseWrapper<T> redoRequest(
593612
String service = token.service();
594613
try {
595614
builder = builder.setHeader(Const.AUTHORIZATION_HEADER, "Bearer " + bearerToken);
596-
HttpResponse<T> newResponse = client.send(builder.build(), handler);
615+
HttpResponse<T> newResponse = executeAndRecordRequest(builder.build(), handler);
597616

598617
// Follow redirect
599618
if (shouldRedirect(newResponse)) {
@@ -608,7 +627,9 @@ private <T> ResponseWrapper<T> redoRequest(
608627
}
609628

610629
return toResponseWrapper(
611-
client.send(builder.uri(URI.create(location)).build(), handler), service);
630+
executeAndRecordRequest(
631+
builder.uri(URI.create(location)).build(), handler),
632+
service);
612633
}
613634
return toResponseWrapper(newResponse, service);
614635

@@ -763,7 +784,6 @@ public Builder withSkipTlsVerify(boolean skipTlsVerify) {
763784

764785
/**
765786
* Set the meter registry for metrics. Following Micrometer best practices for libraries,
766-
* a {@link SimpleMeterRegistry} is used by default when no registry is provided.
767787
* @param meterRegistry The meter registry
768788
* @return The builder
769789
*/

src/main/java/land/oras/auth/TokenCache.java

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
import com.github.benmanes.caffeine.cache.Cache;
2424
import com.github.benmanes.caffeine.cache.Caffeine;
2525
import com.github.benmanes.caffeine.cache.Expiry;
26+
import io.micrometer.core.instrument.MeterRegistry;
27+
import io.micrometer.core.instrument.Metrics;
28+
import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics;
2629
import java.util.concurrent.TimeUnit;
2730
import org.jspecify.annotations.NullMarked;
2831
import org.jspecify.annotations.Nullable;
@@ -35,6 +38,11 @@
3538
@NullMarked
3639
public final class TokenCache {
3740

41+
/**
42+
* Use global registry by default
43+
*/
44+
private static MeterRegistry meterRegistry = Metrics.globalRegistry;
45+
3846
/**
3947
* Hard cache limit
4048
*/
@@ -52,36 +60,51 @@ private TokenCache() {
5260
// Private constructor to prevent instantiation
5361
}
5462

63+
/**
64+
* The cache
65+
*/
66+
private static final Cache<Scopes, HttpClient.TokenResponse> CACHE;
67+
68+
static {
69+
CACHE = Caffeine.newBuilder()
70+
.maximumSize(MAX_CACHE_SIZE)
71+
.recordStats()
72+
.expireAfter(new Expiry<Scopes, HttpClient.TokenResponse>() {
73+
@Override
74+
public long expireAfterCreate(Scopes key, HttpClient.TokenResponse token, long currentTime) {
75+
return getExpiration(token);
76+
}
77+
78+
@Override
79+
public long expireAfterUpdate(
80+
Scopes key, HttpClient.TokenResponse token, long currentTime, long currentDuration) {
81+
return currentDuration;
82+
}
83+
84+
@Override
85+
public long expireAfterRead(
86+
Scopes key, HttpClient.TokenResponse token, long currentTime, long currentDuration) {
87+
return currentDuration;
88+
}
89+
})
90+
.build();
91+
CaffeineCacheMetrics.monitor(TokenCache.meterRegistry, CACHE, "land.oras.token.cache");
92+
}
93+
5594
/**
5695
* Cache for storing service information based on the service URL. This is used to avoid redundant
5796
*/
5897
private static final Cache<String, String> SERVICE_CACHE =
5998
Caffeine.newBuilder().maximumSize(MAX_CACHE_SIZE).build();
6099

61100
/**
62-
* The cache
101+
* Set the meter registry for monitoring the cache metrics
102+
* @param meterRegistry the meter registry to use for monitoring the cache metrics
63103
*/
64-
private static final Cache<Scopes, HttpClient.TokenResponse> CACHE = Caffeine.newBuilder()
65-
.maximumSize(MAX_CACHE_SIZE)
66-
.expireAfter(new Expiry<Scopes, HttpClient.TokenResponse>() {
67-
@Override
68-
public long expireAfterCreate(Scopes key, HttpClient.TokenResponse token, long currentTime) {
69-
return getExpiration(token);
70-
}
71-
72-
@Override
73-
public long expireAfterUpdate(
74-
Scopes key, HttpClient.TokenResponse token, long currentTime, long currentDuration) {
75-
return currentDuration;
76-
}
77-
78-
@Override
79-
public long expireAfterRead(
80-
Scopes key, HttpClient.TokenResponse token, long currentTime, long currentDuration) {
81-
return currentDuration;
82-
}
83-
})
84-
.build();
104+
public static void setMeterRegistry(MeterRegistry meterRegistry) {
105+
TokenCache.meterRegistry = meterRegistry;
106+
CaffeineCacheMetrics.monitor(TokenCache.meterRegistry, CACHE, "land.oras.token.cache");
107+
}
85108

86109
/**
87110
* Put a token response in the cache with the associated scopes.

src/main/java/land/oras/utils/Const.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,12 @@ public static String currentTimestamp() {
444444
/**
445445
* Metric name for token refresh counter
446446
*/
447-
public static final String METRIC_TOKEN_REFRESH = "land_oras_auth_token_refresh_total";
447+
public static final String METRIC_TOKEN_REFRESH = "land.oras.auth.token.refresh";
448+
449+
/**
450+
* Metric name for HTTP request
451+
*/
452+
public static final String METRIC_HTTP_REQUESTS = "http.client.requests";
448453

449454
/**
450455
* Metric name for token refresh duration

src/test/java/land/oras/DockerIoITCase.java

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@
2323
import static org.junit.jupiter.api.Assertions.*;
2424

2525
import io.micrometer.core.instrument.Counter;
26-
import io.micrometer.core.instrument.MeterRegistry;
27-
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
26+
import io.micrometer.core.instrument.Metrics;
2827
import java.nio.file.Path;
2928
import land.oras.utils.Const;
3029
import land.oras.utils.ZotUnsecureContainer;
@@ -110,12 +109,8 @@ void shouldPullOneBlob() {
110109
void shouldCopyTagToInternalRegistry() {
111110

112111
// Source registry
113-
MeterRegistry meterRegistry = new SimpleMeterRegistry();
114-
Registry sourceRegistry = Registry.Builder.builder()
115-
.withMeterRegistry(meterRegistry)
116-
.withParallelism(3)
117-
.defaults()
118-
.build();
112+
Registry sourceRegistry =
113+
Registry.Builder.builder().withParallelism(3).defaults().build();
119114

120115
// Copy to this internal registry
121116
Registry targetRegistry = Registry.Builder.builder()
@@ -133,9 +128,11 @@ void shouldCopyTagToInternalRegistry() {
133128

134129
assertEquals(
135130
1.0,
136-
meterRegistry.find(Const.METRIC_TOKEN_REFRESH).counters().stream()
131+
Metrics.globalRegistry.find(Const.METRIC_TOKEN_REFRESH).counters().stream()
137132
.mapToDouble(Counter::count)
138133
.sum());
134+
135+
TestUtils.dumpMetrics(Metrics.globalRegistry);
139136
}
140137

141138
@Test

src/test/java/land/oras/RegistryWireMockTest.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
3333
import com.github.tomakehurst.wiremock.stubbing.Scenario;
3434
import io.micrometer.core.instrument.Counter;
35+
import io.micrometer.core.instrument.Metrics;
3536
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
3637
import java.io.IOException;
3738
import java.io.InputStream;
@@ -558,6 +559,9 @@ void shouldGetAuthToken(WireMockRuntimeInfo wmRuntimeInfo) {
558559
@Test
559560
void shouldRefreshExpiredToken(WireMockRuntimeInfo wmRuntimeInfo) {
560561

562+
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
563+
Metrics.addRegistry(meterRegistry);
564+
561565
String digest = SupportedAlgorithm.SHA256.digest("blob-data".getBytes());
562566

563567
// Return data from wiremock
@@ -586,11 +590,9 @@ void shouldRefreshExpiredToken(WireMockRuntimeInfo wmRuntimeInfo) {
586590
WireMock.ok().withBody("blob-data").withHeader(Const.DOCKER_CONTENT_DIGEST_HEADER, digest)));
587591

588592
// Insecure registry with a custom meter registry to track metrics
589-
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
590593
Registry registry = Registry.Builder.builder()
591594
.withAuthProvider(new BearerTokenProvider()) // Already bearer token
592595
.withInsecure(true)
593-
.withMeterRegistry(meterRegistry)
594596
.build();
595597

596598
ContainerRef containerRef =
@@ -616,6 +618,7 @@ void shouldRefreshExpiredToken(WireMockRuntimeInfo wmRuntimeInfo) {
616618
.mapToDouble(Counter::count)
617619
.sum());
618620
TestUtils.dumpMetrics(meterRegistry);
621+
TestUtils.dumpMetrics(Metrics.globalRegistry);
619622
}
620623

621624
@Test

src/test/java/land/oras/auth/TokenCacheTest.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@
2222

2323
import static org.junit.jupiter.api.Assertions.assertEquals;
2424
import static org.junit.jupiter.api.Assertions.assertNull;
25+
import static org.junit.jupiter.api.Assertions.assertTrue;
2526

27+
import io.micrometer.core.instrument.FunctionCounter;
28+
import io.micrometer.core.instrument.MeterRegistry;
29+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
2630
import land.oras.ContainerRef;
31+
import land.oras.TestUtils;
2732
import org.junit.jupiter.api.BeforeAll;
2833
import org.junit.jupiter.api.Test;
2934
import org.junit.jupiter.api.parallel.Execution;
@@ -40,7 +45,10 @@ static void beforeAll() {
4045
}
4146

4247
@Test
43-
void shouldLooupWithGlobalScope() {
48+
@Execution(ExecutionMode.SAME_THREAD)
49+
void shouldLookupWithGlobalScope() {
50+
MeterRegistry meterRegistry = new SimpleMeterRegistry();
51+
TokenCache.setMeterRegistry(meterRegistry);
4452
HttpClient.TokenResponse tokenResponse =
4553
new HttpClient.TokenResponse("other-token", null, "dockerhub", 1, null);
4654
ContainerRef containerRef = ContainerRef.parse("docker.io/library/alpine:latest");
@@ -50,10 +58,22 @@ void shouldLooupWithGlobalScope() {
5058
tokenResponse,
5159
TokenCache.get(Scopes.empty(containerRef, "dockerhub").withAddedGlobalScopes("aws")),
5260
"Should retrieve the token before expiration");
61+
TestUtils.dumpMetrics(meterRegistry);
62+
63+
// At least one hit
64+
assertTrue(
65+
meterRegistry.find("cache.gets").tags("result", "hit").functionCounters().stream()
66+
.mapToDouble(FunctionCounter::count)
67+
.sum()
68+
>= 1,
69+
"Should have at least one cache hit");
5370
}
5471

5572
@Test
73+
@Execution(ExecutionMode.SAME_THREAD)
5674
void shouldAddAndRetrieveTokenThenExpiredIt() throws InterruptedException {
75+
MeterRegistry meterRegistry = new SimpleMeterRegistry();
76+
TokenCache.setMeterRegistry(meterRegistry);
5777
HttpClient.TokenResponse tokenResponse =
5878
new HttpClient.TokenResponse("other-token", null, "dockerhub", 1, null);
5979
ContainerRef containerRef = ContainerRef.parse("docker.io/library/alpine0:latest");
@@ -62,6 +82,14 @@ void shouldAddAndRetrieveTokenThenExpiredIt() throws InterruptedException {
6282
assertEquals(tokenResponse, TokenCache.get(scopes), "Should retrieve the token before expiration");
6383
Thread.sleep(1500); // Wait for the token to expire
6484
assertNull(TokenCache.get(scopes), "Should return null after token expiration");
85+
TestUtils.dumpMetrics(meterRegistry);
86+
// At least one eviction
87+
assertTrue(
88+
meterRegistry.find("cache.evictions").functionCounters().stream()
89+
.mapToDouble(FunctionCounter::count)
90+
.sum()
91+
>= 1,
92+
"Should have at least one eviction");
6593
}
6694

6795
@Test

0 commit comments

Comments
 (0)