Problem
Building a ClientFactory with .tls(TlsKeyPair) emits a duplicate Micrometer gauge warning, even when the factory uses an isolated MeterRegistry with no other factories sharing it:
WARN io.micrometer.core.instrument.MeterRegistry - This Gauge has been already
registered (MeterId{name='armeria.client.tls.certificate.validity',
tags=[tag(hostname=<cert-cn>)]}), the registration will be ignored. Note that
subsequent logs will be logged at debug level.
This is distinct from #6387 (inter-factory collision). Here the collision happens within a single factory. Minimal reproducer:
SimpleMeterRegistry registry = new SimpleMeterRegistry();
ClientFactory factory = ClientFactory.builder()
.tls(TlsKeyPair.ofSelfSigned("test"))
.meterRegistry(registry) // dedicated, nothing else writes to it
.build();
// → WARN "This Gauge has been already registered ..."
Enabling DEBUG on io.micrometer.core.instrument.MeterRegistry shows exactly two "already registered" events per factory build (one per metric in the pair certificate.validity + certificate.validity.days).
Root cause
BootstrapSslContexts iterates the 3 entries of SessionProtocol.httpsValues() per factory:
// core/src/main/java/com/linecorp/armeria/client/BootstrapSslContexts.java
for (SessionProtocol sessionProtocol: SessionProtocol.httpsValues()) { // {HTTPS, H1, H2}
final ClientTlsSpec tlsSpec = baseClientTlsSpec.toBuilder()
.engineType(options.tlsEngineType())
.alpnProtocols(sessionProtocol)
.tlsCustomizer(options.tlsCustomizer())
.build();
tlsSpecsBuilder.put(sessionProtocol, tlsSpec);
sslContextsBuilder.put(sessionProtocol, sslContextFactory.getOrCreate(tlsSpec));
}
ClientTlsSpecBuilder.alpnProtocols(SessionProtocol) collapses the 3 protocols into 2 distinct ALPN sets:
// core/src/main/java/com/linecorp/armeria/client/ClientTlsSpecBuilder.java
ClientTlsSpecBuilder alpnProtocols(SessionProtocol sessionProtocol) {
if (sessionProtocol.isExplicitHttp1()) {
alpnProtocols = SslContextUtil.DEFAULT_HTTP1_ALPN_PROTOCOLS;
} else {
alpnProtocols = SslContextUtil.DEFAULT_ALPN_PROTOCOLS;
}
return this;
}
Since isExplicitHttp1() is true only for H1/H1C, HTTPS and H2 produce identical ClientTlsSpecs; H1 produces a distinct one. SslContextFactory.getOrCreate dedupes via cache.computeIfAbsent, so toContextHolder runs twice per factory.
toContextHolder unconditionally binds cert metrics on each call:
// core/src/main/java/com/linecorp/armeria/internal/common/SslContextFactory.java
private SslContextHolder toContextHolder(AbstractTlsSpec tlsSpec, SslContext sslContext) {
...
if (!certs.isEmpty()) {
final MeterIdPrefix meterIdPrefix = meterIdPrefix(tlsSpec);
meterBinder = MoreMeterBinders.certificateMetrics(certs, meterIdPrefix);
meterBinder.bindTo(meterRegistry); // runs twice → duplicate
}
...
}
The MeterIds produced by the two calls are byte-identical because meterIdPrefix(tlsSpec) does not incorporate alpnProtocols (or any other field that differs between the two specs) — for clients it returns a constant CLIENT_METER_ID_PREFIX, and the hostname tag comes from the cert's CN inside CertificateMetrics. Same prefix + same cert set → same MeterId → Micrometer rejects the second registration.
Suggested fix
Inside SslContextFactory, bind cert metrics once per unique cert set rather than once per SslContextHolder. Key a cache on (meterRegistry, cert-set-identity) so the first holder referencing a given cert set installs the binder, and subsequent holders reuse it. Tie the unbind to the existing SslContextHolder ref-count so the binder is released when the last referencing holder is released — this composes with the existing lifecycle rather than leaking binders on teardown.
Environment
- Armeria: 1.37.0; behavior also confirmed on
main (April 2026)
- JVM: Java 24
- Observed with both
TlsKeyPair.ofSelfSigned(...) and production mTLS key pairs
Problem
Building a
ClientFactorywith.tls(TlsKeyPair)emits a duplicate Micrometer gauge warning, even when the factory uses an isolatedMeterRegistrywith no other factories sharing it:This is distinct from #6387 (inter-factory collision). Here the collision happens within a single factory. Minimal reproducer:
Enabling DEBUG on
io.micrometer.core.instrument.MeterRegistryshows exactly two "already registered" events per factory build (one per metric in the paircertificate.validity+certificate.validity.days).Root cause
BootstrapSslContextsiterates the 3 entries ofSessionProtocol.httpsValues()per factory:ClientTlsSpecBuilder.alpnProtocols(SessionProtocol)collapses the 3 protocols into 2 distinct ALPN sets:Since
isExplicitHttp1()is true only forH1/H1C,HTTPSandH2produce identicalClientTlsSpecs;H1produces a distinct one.SslContextFactory.getOrCreatededupes viacache.computeIfAbsent, sotoContextHolderruns twice per factory.toContextHolderunconditionally binds cert metrics on each call:The
MeterIds produced by the two calls are byte-identical becausemeterIdPrefix(tlsSpec)does not incorporatealpnProtocols(or any other field that differs between the two specs) — for clients it returns a constantCLIENT_METER_ID_PREFIX, and thehostnametag comes from the cert's CN insideCertificateMetrics. Same prefix + same cert set → sameMeterId→ Micrometer rejects the second registration.Suggested fix
Inside
SslContextFactory, bind cert metrics once per unique cert set rather than once perSslContextHolder. Key a cache on(meterRegistry, cert-set-identity)so the first holder referencing a given cert set installs the binder, and subsequent holders reuse it. Tie the unbind to the existingSslContextHolderref-count so the binder is released when the last referencing holder is released — this composes with the existing lifecycle rather than leaking binders on teardown.Environment
main(April 2026)TlsKeyPair.ofSelfSigned(...)and production mTLS key pairs