Skip to content

armeria.client.tls.certificate.validity gauge registered twice per ClientFactory #6734

Description

@lanfeitiao

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions