Dirac s’oppose à l’instrumentation par réflexion à chaud : tout le câblage est fait au démarrage du container CDI, et le chemin chaud — incrémenter un compteur, mesurer une durée — n’engage ni verrou, ni allocation parasite, ni synchronized. Cette page décrit la séquence exacte et les invariants de threading.
Séparation dirac-core / dirac-cdi-vauban
dirac-core est du Java pur. Il ne connaît ni CDI, ni JAX-RS — uniquement la spec MP Metrics. Les types CounterImpl, GaugeImpl, HistogramImpl, TimerImpl, MetricRegistryImpl, BaseMetricsRegistrar, OpenMetricsFormatter et JsonMetricsFormatter sont testables unitairement sans container, et utilisables hors CDI (un outillage Maven ou un test JMH peut les consommer directement).
dirac-cdi-vauban est la couche d’intégration : intercepteurs @Counted et @Timed, BCE DiracExtension qui valide et résout les @Gauge, producer MetricRegistryProducerBean qui expose les trois MetricRegistry.
dirac-rest est la couche HTTP optionnelle : MetricsEndpoint consomme les registres et délègue à un formatter selon Accept.
Pipeline de démarrage
-
DiracExtensionest découverte par Vauban via leServiceLoader(provides BuildCompatibleExtension). -
Phase
@Enhancement— collecte des classes annotées@Gauge; chaque méthode est résolue enMethodHandle(privilégierMethodHandles.privateLookupInplutôt quesetAccessible). -
Phase
@Synthesis— synthèse duMetricRegistryProducerBean@ApplicationScoped, propriétaire des trois registres. -
À l’activation du container,
@PostConstructdu producer instancie lesMetricRegistryImplet fait appel àBaseMetricsRegistrarpour peupler le scopeBASE(JVM gauges, GC, threads, heap). -
Les
Gaugecollectées sont enregistrées commeGauge<T>dans le scope cible (APPLICATIONpar défaut).
Aucune classe utilisateur n’est instanciée tant qu’elle n’est pas effectivement injectée : la résolution MethodHandle est faite sur la métadonnée, pas sur l’instance.
Chemin chaud @Counted
L’intercepteur CountedInterceptor est dispatché par CDI à chaque invocation. Il maintient un cache ConcurrentHashMap<CacheKey, ResolvedCounter> indexé par (BeanClass, Method) — la résolution MetricID n’a lieu qu’une fois, au premier appel ; les suivants tirent du cache.
// extrait simplifié de CountedInterceptor.aroundInvoke
ResolvedCounter resolved = counters.computeIfAbsent(
new CacheKey(beanClass, method),
k -> resolve(beanClass, method));
resolved.counter.inc(); // LongAdder — pas de contention
return ctx.proceed();
Counter.inc() est un LongAdder.increment() — pas de CAS, pas de synchronized. Les Tag sont des record immuables.
TimedInterceptor suit le même schéma : long t0 = System.nanoTime(); avant ctx.proceed(), histogram.update(System.nanoTime() - t0); après.
Registre MetricRegistryImpl
Trois invariants :
-
ConcurrentHashMappar scope — cléMetricID, valeurMetric. Insertion viacomputeIfAbsent, lecture lock-free. -
Idempotence —
counter(metadata, tags)retourne la métrique existante si elle est déjà enregistrée. Pas de doublon possible sur unMetricID. -
Pas de
synchronized— l’enregistrement et la lecture passent uniquement par laConcurrentHashMap. Pas de barrière mémoire critique.
Le registre est @ApplicationScoped : une instance unique par scope, propriété de MetricRegistryProducerBean.
HistogramImpl et percentiles
HistogramImpl utilise trois primitives sans verrou :
-
LongAdder count— compteur d’échantillons. -
LongAdder sum— somme des valeurs. -
LongAccumulator max(Long::max, Long.MIN_VALUE)— maximum. -
ConcurrentLinkedQueue<Long> values— buffer pour calcul de percentiles à la lecture.
Les percentiles sont calculés à la demande par le formatter (tri du buffer puis interpolation), pas à chaque update. Coût d’instrumentation : un add sur une queue lock-free + trois increments d'`adder`.
Les buckets fixes (mp.metrics.distribution.histogram.buckets) ajoutent une famille _bucket{le=…} à l’export OpenMetrics, calculée par parcours du buffer.
TimerImpl
TimerImpl enveloppe un HistogramImpl (avec timerMetric=true pour activer le profil de buckets timer). La méthode time(Runnable) mesure via System.nanoTime(). Pas de ThreadLocal, pas de Stopwatch thread-bound : la mesure se fait sur la pile de l’appel.
Format OpenMetrics
OpenMetricsFormatter écrit directement dans un OutputStream via StringBuilder et getBytes(UTF_8). Aucune librairie tierce, aucun moteur de templating.
Convention : pour les Timer, le type émis est summary (lignes _sum, _count, quantile), conforme à https://openmetrics.io. Pour les Histogram avec buckets, le type est histogram (lignes _bucket{le=…}, _sum, _count).
Format JSON
JsonMetricsFormatter produit l’arborescence MP Metrics §3.2 — un objet par scope, contenant un objet par famille de métrique. Le sérialiseur est écrit à la main avec StringBuilder (échappement minimal des chaînes, pas de pretty-print) — dans l’esprit de Champollion, sans Jackson ni Yasson.
Modèle de threading
Pas de verrou à chaud
Le chemin chaud — inc(), update(long), time(…) — n’engage ni synchronized, ni ReentrantLock. Les structures concurrentes (LongAdder, LongAccumulator, AtomicLongArray, ConcurrentHashMap, ConcurrentLinkedQueue) sont conçues pour la concurrence forte sans cession au scheduler.
Endpoint MetricsEndpoint
MetricsEndpoint est une ressource JAX-RS @ApplicationScoped du module dirac-rest. Trois routes (/metrics, /metrics/{scope}, /metrics/{scope}/{name}), trois étapes par appel :
-
Résoudre le format via
Accept(défaut :text/plain, OpenMetrics). -
Récupérer le registre cible via
MetricRegistryProducerBean.registry(scope). -
Émettre via
OpenMetricsFormatterouJsonMetricsFormatter.
Aucun verrou n’est pris ; la sérialisation peut elle-même se paralléliser sans risque, puisque les lectures de registre sont lock-free.