Dirac rejects instrumentation by hot-path reflection: all wiring is done at CDI container startup, and the hot path — bumping a counter, measuring a duration — engages neither lock, nor stray allocation, nor synchronized. This page describes the exact sequence and the threading invariants.

Separation dirac-core / dirac-cdi-vauban

dirac-core is pure Java. It knows nothing of CDI or JAX-RS — only the MP Metrics spec. CounterImpl, GaugeImpl, HistogramImpl, TimerImpl, MetricRegistryImpl, BaseMetricsRegistrar, OpenMetricsFormatter and JsonMetricsFormatter are unit-testable without a container, and usable outside CDI (a Maven plugin or a JMH benchmark can consume them directly).

dirac-cdi-vauban is the integration layer: @Counted and @Timed interceptors, the DiracExtension BCE that validates and resolves @Gauge, and the MetricRegistryProducerBean producer that exposes the three MetricRegistry.

dirac-rest is the optional HTTP layer: MetricsResource consumes the registries and delegates to a formatter according to Accept, normalised upfront by ContentNegotiationFilter.

Startup pipeline

Diagram
  1. DiracExtension is discovered by Vauban through ServiceLoader (provides BuildCompatibleExtension).

  2. @Discovery phase — registerCustomInterceptorBindings(MetaAnnotations) declares the interceptor bindings backing @Counted and @Timed.

  3. @Enhancement phase — collectMetricAnnotations(ClassInfo, Messages) scans every bean class for @Counted, @Timed and @Gauge, which also validates them (a bad @Gauge fails the deployment), and keeps the name of each class that carries a metric. A class the application cannot load — the container’s own types, on Open Liberty — is skipped.

  4. @Synthesis phase NEW — registerDiscoveredMetrics(SyntheticComponents) registers a synthetic DiscoveredMetrics bean whose parameter lists those class names; DiscoveredMetricsCreator resolves their metrics again, in the container: gauge methods to MethodHandle (preferring MethodHandles.privateLookupIn over setAccessible) when no compile-time $$DiracMetrics companion covers them. The state belongs to the extension instance, so each container has its own: two containers sharing Dirac’s classes, as two applications on one class loader do, never see each other’s metrics (dirac#23). It used to be a static field of the extension.

  5. MetricRegistryProducerBean is a regular @ApplicationScoped bean; its constructor instantiates the three MetricRegistryImpl (APPLICATION, BASE, VENDOR) and invokes BaseMetricsRegistrar to populate the BASE scope (JVM gauges: GC, threads, heap, classloader, CPU, uptime). No @PostConstruct callback is involved.

  6. GaugeRegistrationBean, at application start, takes this container’s DiscoveredMetrics and registers the gathered @Gauge as Gauge<T> in the target scope (APPLICATION by default), and pre-registers the @Timed and @Counted metrics. Gauge beans are looked up in this container, not through CDI.current().

No user class is instantiated until it is actually injected: MethodHandle resolution runs on metadata, not on instances.

@Counted hot path

The CountedInterceptor is dispatched by CDI on every invocation. It maintains a ConcurrentHashMap<CacheKey, ResolvedCounter> cache keyed by (BeanClass, Method) — MetricID resolution happens only once, on the first call; subsequent calls pull from the cache.

// simplified excerpt from CountedInterceptor.aroundInvoke
ResolvedCounter resolved = counters.computeIfAbsent(
    new CacheKey(beanClass, method),
    k -> resolve(beanClass, method));
resolved.counter.inc();        // LongAdder — no contention
return ctx.proceed();

Counter.inc() is a LongAdder.increment() — no CAS, no synchronized. Tag is an immutable record.

TimedInterceptor follows the same scheme: long t0 = System.nanoTime(); before ctx.proceed(), histogram.update(System.nanoTime() - t0); after.

MetricRegistryImpl

MetricRegistryImpl is a single public final class implementing the spec’s MetricRegistry directly — no intermediate Dirac interface layer. Three invariants:

  1. ConcurrentHashMap per scope — key MetricID, value Metric. Insert via computeIfAbsent, lock-free read.

  2. Idempotence — counter(metadata, tags) returns the existing metric if already registered. No duplicate on a MetricID.

  3. No synchronized — registration and reads go through the ConcurrentHashMap only. No critical memory barrier.

The registry is @ApplicationScoped: one instance per scope, owned by MetricRegistryProducerBean.

HistogramImpl and percentiles

HistogramImpl uses four lock-free primitives:

  • LongAdder count — sample counter.

  • LongAdder sum — sum of values.

  • LongAccumulator max(Long::max, Long.MIN_VALUE) — maximum.

  • ConcurrentLinkedQueue<Long> values — buffer for read-time percentile computation.

Percentiles are computed lazily by the formatter (buffer sort + interpolation), not on every update. Instrumentation cost: one add on a lock-free queue plus three adder increments.

Fixed buckets (mp.metrics.distribution.histogram.buckets) add a _bucket{le=…​} family to the OpenMetrics output, computed by buffer scan.

TimerImpl

TimerImpl wraps a HistogramImpl (with timerMetric=true to enable the timer-bucket profile). The time(Runnable) method measures via System.nanoTime(). No ThreadLocal, no thread-bound Stopwatch: the measurement lives on the call stack.

OpenMetrics format

OpenMetricsFormatter builds the whole exposition with a StringBuilder and returns it as a String (public String format(MetricRegistry)); MetricsResource hands that String to JAX-RS. No third-party library, no templating engine.

Conventions: Counter samples carry a _total suffix (added unless the name already ends with it); Timer is emitted as summary under <name>_seconds samples (quantile labels, _seconds_count, _seconds_sum, values converted from nanoseconds); Histogram with buckets is emitted as histogram (lines _bucket{le=…​} plus the +Inf bucket, _count, _sum). Labels are the metric’s own tags only — no mp_scope label — and the output is terminated by the # EOF marker.

JSON format

JsonMetricsFormatter produces the MP Metrics §3.2 tree — one object per scope, containing one object per metric family. The serialiser is hand-written with StringBuilder (minimal string escaping, no pretty-printing) — in the spirit of Champollion, without Jackson or Yasson.

Threading model

No hot-path locks

The hot path — inc(), update(long), time(…​) — engages neither synchronized nor ReentrantLock. The concurrent data structures (LongAdder, LongAccumulator, ConcurrentHashMap, ConcurrentLinkedQueue) are designed for heavy concurrency with no yield to the scheduler.

No ThreadLocal

Per the module’s CLAUDE.md, no ThreadLocal is used in production. When state sharing is required, the concurrent structures above are preferred.

Virtual threads

On a host runtime that runs on virtual threads (for example Chappe with Executors.newVirtualThreadPerTaskExecutor()), Dirac introduces no platform contention: LongAdder and ConcurrentHashMap are indifferent to the kind of carrier thread.

MetricsResource

MetricsResource is an @ApplicationScoped JAX-RS resource in dirac-rest, paired with ContentNegotiationFilter — a @PreMatching ContainerRequestFilter that rewrites a missing, blank or wildcard Accept header to text/plain before routing. The resource holds the three registries as three injected fields:

@Inject @RegistryScope
MetricRegistry appRegistry;

@Inject @RegistryScope(scope = MetricRegistry.BASE_SCOPE)
MetricRegistry baseRegistry;

@Inject @RegistryScope(scope = MetricRegistry.VENDOR_SCOPE)
MetricRegistry vendorRegistry;

Three routes (/metrics, /metrics/{scope}, /metrics/{scope}/{name}), three steps per call:

  1. Resolve the format via Accept (JSON only when it contains application/json; otherwise OpenMetrics, served as text/plain;version=0.0.4;charset=utf-8).

  2. Pick the target registry among the three injected fields.

  3. Emit via OpenMetricsFormatter or JsonMetricsFormatter.

No lock is taken; the serialisation itself can be parallelised safely, since registry reads are lock-free.

Further reading