From 52ffb7e8ab1a5ac1c166e0c2b791b936d5dce488 Mon Sep 17 00:00:00 2001 From: Anton Kurako Date: Sat, 18 Jul 2026 22:51:41 +0300 Subject: [PATCH 1/2] Added HTTP server metrics modes with bounded VictoriaMetrics histograms Added configurable HTTP server duration metric modes for summary, SLO, and VictoriaMetrics output. VM mode exports vmrange buckets through a Micrometer-backed collector while bounding exported bucket cardinality with metrics.vm.min, metrics.vm.max, and metrics.vm.buckets defaults. - Added VictoriaMetrics histogram support with native VM bucket ranges, exact count and sum, and bounded bucket export per label set. - Added HTTP server metric mode selection so VM mode is used only for PrometheusMeterRegistry and existing SLO behavior remains the default. --- http/http-server-common/build.gradle | 4 + .../impl/DefaultHttpServerMetricsFactory.java | 80 +++- .../src/main/java/module-info.java | 3 + micrometer/micrometer-module/build.gradle | 10 +- .../module/VictoriaMetricsHistogram.java | 440 ++++++++++++++++++ .../telemetry/common/TelemetryConfig.java | 28 ++ 6 files changed, 555 insertions(+), 10 deletions(-) create mode 100644 micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java diff --git a/http/http-server-common/build.gradle b/http/http-server-common/build.gradle index 715be3ef7..642dd28ee 100644 --- a/http/http-server-common/build.gradle +++ b/http/http-server-common/build.gradle @@ -5,12 +5,16 @@ plugins { dependencies { annotationProcessor project(':config:config-annotation-processor') + compileOnly project(':micrometer:micrometer-module') + api project(':core:common') api project(':logging:logging-common') api project(':telemetry:telemetry-common') api project(':http:http-common') testImplementation project(':config:config-hocon') + testImplementation project(':micrometer:micrometer-module') + testImplementation libs.micrometer.registry.prometheus testFixturesApi libs.okhttp testFixturesImplementation libs.junit.jupiter diff --git a/http/http-server-common/src/main/java/io/koraframework/http/server/common/telemetry/impl/DefaultHttpServerMetricsFactory.java b/http/http-server-common/src/main/java/io/koraframework/http/server/common/telemetry/impl/DefaultHttpServerMetricsFactory.java index 20680aecc..d7ff92330 100644 --- a/http/http-server-common/src/main/java/io/koraframework/http/server/common/telemetry/impl/DefaultHttpServerMetricsFactory.java +++ b/http/http-server-common/src/main/java/io/koraframework/http/server/common/telemetry/impl/DefaultHttpServerMetricsFactory.java @@ -2,10 +2,13 @@ import io.koraframework.http.server.common.request.HttpServerRequest; import io.koraframework.http.server.common.response.HttpServerResponse; +import io.koraframework.micrometer.module.VictoriaMetricsHistogram; +import io.koraframework.telemetry.common.TelemetryConfig; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Tags; import io.micrometer.core.instrument.Timer; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; import io.opentelemetry.semconv.ErrorAttributes; import io.opentelemetry.semconv.HttpAttributes; import io.opentelemetry.semconv.ServerAttributes; @@ -24,7 +27,11 @@ public class DefaultHttpServerMetricsFactory { public static final DefaultHttpServerMetricsFactory INSTANCE = new DefaultHttpServerMetricsFactory(); public DefaultHttpServerMetrics create(DefaultHttpServerTelemetry.TelemetryContext context) { - return new DefaultHttpServerMetrics(context); + return switch (context.config().metrics().mode()) { + case SUMMARY -> new SummaryHttpServerMetrics(context); + case SLO -> new DefaultHttpServerMetrics(context); + case VM -> new VictoriaMetricsHttpServerMetrics(context); + }; } public static class DefaultHttpServerMetrics { @@ -97,6 +104,15 @@ protected Timer.Builder createMetricServerDuration(DurationKey metricKey, HttpServerRequest request, HttpServerResponse response, @Nullable Throwable throwable) { + return Timer.builder("http.server.request.duration") + .serviceLevelObjectives(this.context.config().metrics().slo()) + .tags(createMetricServerDurationTags(metricKey, request, response, throwable)); + } + + protected Tags createMetricServerDurationTags(DurationKey metricKey, + HttpServerRequest request, + HttpServerResponse response, + @Nullable Throwable throwable) { var extraTags = 0; if (metricKey.extraTags != null) { for (Tag _ : metricKey.extraTags) { @@ -105,7 +121,7 @@ protected Timer.Builder createMetricServerDuration(DurationKey metricKey, } var staticTags = new ArrayList(5 + this.context.config().metrics().tags().size() + extraTags); - var errorType = (throwable == null) ? "" : throwable.getClass().getCanonicalName(); + var errorType = (metricKey.errorType == null) ? "" : metricKey.errorType.getCanonicalName(); staticTags.add(Tag.of(HttpAttributes.HTTP_REQUEST_METHOD.getKey(), request.method())); staticTags.add(Tag.of(HttpAttributes.HTTP_ROUTE.getKey(), metricKey.pathTemplate())); staticTags.add(Tag.of(UrlAttributes.URL_SCHEME.getKey(), request.scheme())); @@ -121,9 +137,7 @@ protected Timer.Builder createMetricServerDuration(DurationKey metricKey, } } - return Timer.builder("http.server.request.duration") - .serviceLevelObjectives(this.context.config().metrics().slo()) - .tags(Tags.of(staticTags)); + return Tags.of(staticTags); } protected ActiveRequestsKey createMetricActiveRequestsGaugeKey(HttpServerRequest request) { @@ -171,4 +185,60 @@ protected AtomicLong createMetricActiveRequests(ActiveRequestsKey metricKey, Htt return value; } } + + public static final class SummaryHttpServerMetrics extends DefaultHttpServerMetrics { + + public SummaryHttpServerMetrics(DefaultHttpServerTelemetry.TelemetryContext context) { + super(context); + } + + @Override + protected Timer.Builder createMetricServerDuration(DurationKey metricKey, + HttpServerRequest request, + HttpServerResponse response, + @Nullable Throwable throwable) { + return super.createMetricServerDuration(metricKey, request, response, throwable) + .serviceLevelObjectives(); + } + } + + public static final class VictoriaMetricsHttpServerMetrics extends DefaultHttpServerMetrics { + + private final PrometheusMeterRegistry meterRegistry; + private final ConcurrentHashMap requestDurationCache = new ConcurrentHashMap<>(); + + public VictoriaMetricsHttpServerMetrics(DefaultHttpServerTelemetry.TelemetryContext context) { + super(context); + var meterRegistry = context.meterRegistry(); + if (!(meterRegistry instanceof PrometheusMeterRegistry prometheusMeterRegistry)) { + throw new IllegalStateException("%s=%s requires PrometheusMeterRegistry" + .formatted(TelemetryConfig.MetricsConfig.class.getSimpleName() + ".mode", TelemetryConfig.MetricsConfig.MetricsMode.VM)); + } + this.meterRegistry = prometheusMeterRegistry; + } + + @Override + public void recordEnd(HttpServerRequest request, + HttpServerResponse response, + @Nullable Throwable exception, + long processingTimeNanos) { + var key = createMetricServerDurationKey(request, response, exception); + var meter = this.requestDurationCache.computeIfAbsent(key, _ -> createVictoriaMetricsServerDuration(key, request, response, exception)); + meter.record(processingTimeNanos, TimeUnit.NANOSECONDS); + createMetricActiveRequestsGaugeCounter(request).decrementAndGet(); + } + + private VictoriaMetricsHistogram createVictoriaMetricsServerDuration(DurationKey metricKey, + HttpServerRequest request, + HttpServerResponse response, + @Nullable Throwable throwable) { + return VictoriaMetricsHistogram.builder("http.server.request.duration") + .baseUnit("seconds") + .min(this.context.config().metrics().vm().min()) + .max(this.context.config().metrics().vm().max()) + .buckets(this.context.config().metrics().vm().buckets()) + .tags(createMetricServerDurationTags(metricKey, request, response, throwable)) + .register(this.meterRegistry); + } + } } diff --git a/http/http-server-common/src/main/java/module-info.java b/http/http-server-common/src/main/java/module-info.java index eeae4116a..b647c8ace 100644 --- a/http/http-server-common/src/main/java/module-info.java +++ b/http/http-server-common/src/main/java/module-info.java @@ -5,6 +5,9 @@ requires transitive kora.http.common; requires transitive kora.logging.common; requires transitive kora.telemetry.common; + requires transitive micrometer.core; + requires kora.micrometer.module; + requires micrometer.registry.prometheus; exports io.koraframework.http.server.common; exports io.koraframework.http.server.common.annotation; diff --git a/micrometer/micrometer-module/build.gradle b/micrometer/micrometer-module/build.gradle index dad0562cc..46002e323 100644 --- a/micrometer/micrometer-module/build.gradle +++ b/micrometer/micrometer-module/build.gradle @@ -1,11 +1,11 @@ dependencies { + annotationProcessor project(':config:config-annotation-processor') + + compileOnly project(':resilient:resilient-kora') + + api project(":telemetry:telemetry-common") api libs.micrometer.core api libs.micrometer.registry.prometheus api libs.opentelemetry.api api libs.opentelemetry.micrometer.meter.provider - api project(":telemetry:telemetry-common") - - annotationProcessor project(':config:config-annotation-processor') - - compileOnly project(':resilient:resilient-kora') } diff --git a/micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java b/micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java new file mode 100644 index 000000000..73dfe70f3 --- /dev/null +++ b/micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java @@ -0,0 +1,440 @@ +package io.koraframework.micrometer.module; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import io.prometheus.metrics.model.registry.MultiCollector; +import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.UnknownSnapshot; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Locale; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicLongArray; +import java.util.concurrent.atomic.AtomicReferenceArray; +import java.util.concurrent.atomic.DoubleAdder; + +public final class VictoriaMetricsHistogram { + + private static final int E10_MIN = -9; + private static final int E10_MAX = 18; + private static final int DECIMAL_MULTIPLIER = 2; + private static final int BUCKET_SIZE = 9 * DECIMAL_MULTIPLIER; + private static final int BUCKETS_COUNT = E10_MAX - E10_MIN; + private static final double DECIMAL_PRECISION = 0.01 / DECIMAL_MULTIPLIER; + + private static final IdxOffset ZERO = new IdxOffset(-1, 0); + private static final IdxOffset LOWER = new IdxOffset(-1, 1); + private static final IdxOffset UPPER = new IdxOffset(-1, 2); + + private static final String[] VM_RANGES = new String[3 + BUCKETS_COUNT * BUCKET_SIZE]; + private static final String[] VM_RANGE_STARTS = new String[VM_RANGES.length]; + private static final String[] VM_RANGE_ENDS = new String[VM_RANGES.length]; + private static final Map> COLLECTORS = new WeakHashMap<>(); + + static { + VM_RANGE_STARTS[0] = "0"; + VM_RANGE_ENDS[0] = "0"; + VM_RANGES[0] = VM_RANGE_STARTS[0] + "..." + VM_RANGE_ENDS[0]; + VM_RANGE_STARTS[1] = "0"; + VM_RANGE_ENDS[1] = String.format(Locale.US, "%.1fe%d", 1.0, E10_MIN); + VM_RANGES[1] = VM_RANGE_STARTS[1] + "..." + VM_RANGE_ENDS[1]; + VM_RANGE_STARTS[2] = String.format(Locale.US, "%.1fe%d", 1.0, E10_MAX); + VM_RANGE_ENDS[2] = "+Inf"; + VM_RANGES[2] = VM_RANGE_STARTS[2] + "..." + VM_RANGE_ENDS[2]; + + var start = String.format(Locale.US, "%.1fe%d", 1.0, E10_MIN); + var idx = 3; + for (int bucketIdx = 0; bucketIdx < BUCKETS_COUNT; bucketIdx++) { + for (int offset = 0; offset < BUCKET_SIZE; offset++) { + var e10 = E10_MIN + bucketIdx; + var m = 1 + (double) (offset + 1) / DECIMAL_MULTIPLIER; + if (Math.abs(m - 10) < DECIMAL_PRECISION) { + m = 1; + e10++; + } + var end = String.format(Locale.US, "%.1fe%d", m, e10); + VM_RANGE_STARTS[idx] = start; + VM_RANGE_ENDS[idx] = end; + VM_RANGES[idx] = start + "..." + end; + start = end; + idx++; + } + } + } + + private final Collector collector; + private final Tags tags; + private final Buckets buckets; + + private VictoriaMetricsHistogram(Collector collector, Tags tags, Buckets buckets) { + this.collector = collector; + this.tags = tags; + this.buckets = buckets; + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public VictoriaMetricsHistogram tags(Tags tags) { + return new VictoriaMetricsHistogram(this.collector, this.tags.and(tags), this.buckets); + } + + public void record(double value) { + this.collector.child(this.tags, this.buckets).record(value); + } + + public void record(long amount, TimeUnit unit) { + this.collector.child(this.tags, this.buckets).record(amount, unit); + } + + private static Collector getOrCreate(PrometheusRegistry registry, String name) { + synchronized (COLLECTORS) { + var collectors = COLLECTORS.computeIfAbsent(registry, _ -> new ConcurrentHashMap<>()); + return collectors.computeIfAbsent(name, _ -> { + var collector = new Collector(name); + registry.register(collector); + return collector; + }); + } + } + + private static Labels toLabels(Tags tags) { + var names = new ArrayList(); + var values = new ArrayList(); + for (Tag tag : tags) { + names.add(tag.getKey()); + values.add(tag.getValue()); + } + return Labels.of(names, values); + } + + private static String prometheusName(String name, @Nullable String baseUnit) { + var result = name.replace('.', '_'); + if (baseUnit != null && !result.endsWith("_" + baseUnit)) { + result += "_" + baseUnit; + } + return result; + } + + private static String getRangeTagValue(IdxOffset idxOffset) { + return VM_RANGES[getRangeIndex(idxOffset.bucketIdx, idxOffset.offset)]; + } + + private static String getRangeTagValue(IdxOffset from, IdxOffset to) { + var fromIndex = getRangeIndex(from.bucketIdx, from.offset); + var toIndex = getRangeIndex(to.bucketIdx, to.offset); + if (fromIndex == toIndex) { + return VM_RANGES[fromIndex]; + } + return VM_RANGE_STARTS[fromIndex] + "..." + VM_RANGE_ENDS[toIndex]; + } + + private static String format(double value) { + var e10 = (int) Math.floor(Math.log10(value)); + var m = value / Math.pow(10, e10); + return String.format(Locale.US, "%.1fe%d", m, e10); + } + + private static int getRangeIndex(int index, int offset) { + if (index < 0) { + return offset; + } + return 3 + index * BUCKET_SIZE + offset; + } + + private static IdxOffset getBucketIdxAndOffset(double value) { + if (value == 0) { + return ZERO; + } + if (Double.POSITIVE_INFINITY == value) { + return UPPER; + } + + var e10 = (int) Math.floor(Math.log10(value)); + var bucketIdx = e10 - E10_MIN; + if (bucketIdx < 0) { + return LOWER; + } + + var pow = Math.pow(10, e10); + if (bucketIdx >= BUCKETS_COUNT) { + if ((bucketIdx == BUCKETS_COUNT) && (Math.abs(pow - value) < DECIMAL_PRECISION)) { + return new IdxOffset(BUCKETS_COUNT - 1, BUCKET_SIZE - 1); + } + return UPPER; + } + + var m = ((value / pow) - 1) * DECIMAL_MULTIPLIER; + var offset = (int) m; + if (offset < 0) { + offset = 0; + } else if (offset >= BUCKET_SIZE) { + offset = BUCKET_SIZE - 1; + } + + if (Math.abs((double) offset - m) < DECIMAL_PRECISION) { + offset--; + if (offset < 0) { + bucketIdx--; + if (bucketIdx < 0) { + return LOWER; + } + offset = BUCKET_SIZE - 1; + } + } + + return new IdxOffset(bucketIdx, offset); + } + + private record IdxOffset(int bucketIdx, int offset) {} + + private record Buckets(double min, double max, IdxOffset minBucket, IdxOffset maxBucket, int size) { + + private Buckets { + if (!Double.isFinite(min) || min <= 0) { + throw new IllegalArgumentException("VictoriaMetrics histogram min must be a positive finite value"); + } + if (!Double.isFinite(max) || max <= min) { + throw new IllegalArgumentException("VictoriaMetrics histogram max must be greater than min"); + } + if (size < 3) { + throw new IllegalArgumentException("VictoriaMetrics histogram buckets must be greater than or equal to 3"); + } + } + } + + public static final class Builder { + + private final String name; + private Tags tags = Tags.empty(); + @Nullable + private String baseUnit; + private Duration min = Duration.ofMillis(1); + private Duration max = Duration.ofSeconds(90); + private int buckets = 16; + + private Builder(String name) { + this.name = name; + } + + public Builder tags(Tags tags) { + this.tags = this.tags.and(tags); + return this; + } + + public Builder baseUnit(String baseUnit) { + this.baseUnit = baseUnit; + return this; + } + + public Builder min(Duration min) { + this.min = min; + return this; + } + + public Builder max(Duration max) { + this.max = max; + return this; + } + + public Builder buckets(int buckets) { + this.buckets = buckets; + return this; + } + + public VictoriaMetricsHistogram register(PrometheusMeterRegistry registry) { + var collector = getOrCreate(registry.getPrometheusRegistry(), prometheusName(this.name, this.baseUnit)); + var min = this.min.toNanos() / 1_000_000_000.0; + var max = this.max.toNanos() / 1_000_000_000.0; + var buckets = new Buckets( + min, + max, + getBucketIdxAndOffset(min), + getBucketIdxAndOffset(max), + this.buckets + ); + return new VictoriaMetricsHistogram(collector, this.tags, buckets); + } + } + + private static final class Collector implements MultiCollector { + + private final String name; + private final ConcurrentHashMap children = new ConcurrentHashMap<>(); + + private Collector(String name) { + this.name = name; + } + + private Child child(Tags tags, Buckets buckets) { + var labels = toLabels(tags); + return this.children.computeIfAbsent(labels, l -> new Child(l, buckets)); + } + + @Override + public MetricSnapshots collect() { + var bucket = UnknownSnapshot.builder().name(this.name + "_bucket").help(""); + var sum = UnknownSnapshot.builder().name(this.name + "_sum").help(""); + var count = UnknownSnapshot.builder().name(this.name + "_count").help(""); + + for (var child : this.children.values()) { + child.collect(bucket, sum, count); + } + + return MetricSnapshots.builder() + .metricSnapshot(bucket.build()) + .metricSnapshot(sum.build()) + .metricSnapshot(count.build()) + .build(); + } + } + + private static final class Child { + + private final Labels labels; + private final Buckets buckets; + private final AtomicReferenceArray values = new AtomicReferenceArray<>(BUCKETS_COUNT); + private final AtomicLong zeros = new AtomicLong(); + private final AtomicLong lower = new AtomicLong(); + private final AtomicLong upper = new AtomicLong(); + private final DoubleAdder sum = new DoubleAdder(); + + private Child(Labels labels, Buckets buckets) { + this.labels = labels; + this.buckets = buckets; + } + + private void record(long amount, TimeUnit unit) { + if (amount < 0) { + return; + } + this.record(unit.toNanos(amount) / 1_000_000_000.0); + } + + private void record(double value) { + if (Double.isNaN(value) || value < 0) { + return; + } + var idx = getBucketIdxAndOffset(value); + this.sum.add(value); + if (idx.bucketIdx < 0) { + if (idx.offset == 0) { + this.zeros.incrementAndGet(); + } else if (idx.offset == 1) { + this.lower.incrementAndGet(); + } else { + this.upper.incrementAndGet(); + } + return; + } + + var bucket = this.values.get(idx.bucketIdx); + if (bucket == null) { + bucket = new AtomicLongArray(BUCKET_SIZE); + if (!this.values.compareAndSet(idx.bucketIdx, null, bucket)) { + bucket = this.values.get(idx.bucketIdx); + } + } + bucket.incrementAndGet(idx.offset); + } + + private void collect(UnknownSnapshot.Builder bucket, UnknownSnapshot.Builder sum, UnknownSnapshot.Builder count) { + var total = 0L; + var observedBuckets = new ArrayList(); + total += collectSpecial(observedBuckets, ZERO, this.zeros.get()); + total += collectSpecial(observedBuckets, LOWER, this.lower.get()); + total += collectSpecial(observedBuckets, UPPER, this.upper.get()); + + for (int i = 0; i < this.values.length(); i++) { + var bucketCounters = this.values.get(i); + if (bucketCounters != null) { + for (int j = 0; j < bucketCounters.length(); j++) { + var value = bucketCounters.get(j); + if (value > 0) { + total += collectSpecial(observedBuckets, new IdxOffset(i, j), value); + } + } + } + } + + if (total == 0) { + return; + } + + if (observedBuckets.size() <= this.buckets.size()) { + for (var observedBucket : observedBuckets) { + bucket.dataPoint(new UnknownSnapshot.UnknownDataPointSnapshot( + observedBucket.value(), + this.labels.add("vmrange", observedBucket.range()), + null + )); + } + } else { + for (int i = 0; i < this.buckets.size(); i++) { + var from = i * observedBuckets.size() / this.buckets.size(); + var to = (i + 1) * observedBuckets.size() / this.buckets.size(); + var value = 0L; + for (int j = from; j < to; j++) { + value += observedBuckets.get(j).value(); + } + bucket.dataPoint(new UnknownSnapshot.UnknownDataPointSnapshot( + value, + this.labels.add("vmrange", range(observedBuckets.get(from), observedBuckets.get(to - 1))), + null + )); + } + } + + sum.dataPoint(new UnknownSnapshot.UnknownDataPointSnapshot(this.sum.sum(), this.labels, null)); + count.dataPoint(new UnknownSnapshot.UnknownDataPointSnapshot(total, this.labels, null)); + } + + private long collectSpecial(ArrayList bucket, IdxOffset idx, long value) { + if (value > 0) { + var rangeIndex = getRangeIndex(idx.bucketIdx, idx.offset); + var minIndex = getRangeIndex(this.buckets.minBucket().bucketIdx, this.buckets.minBucket().offset); + var maxIndex = getRangeIndex(this.buckets.maxBucket().bucketIdx, this.buckets.maxBucket().offset); + if (rangeIndex < minIndex) { + merge(bucket, new Bucket(idx, "0..." + format(this.buckets.min()), value)); + } else if (rangeIndex > maxIndex) { + merge(bucket, new Bucket(idx, format(this.buckets.max()) + "...+Inf", value)); + } else { + bucket.add(new Bucket(idx, getRangeTagValue(idx), value)); + } + } + return value; + } + + private static void merge(ArrayList buckets, Bucket bucket) { + if (!buckets.isEmpty()) { + var last = buckets.get(buckets.size() - 1); + if (last.range().equals(bucket.range())) { + buckets.set(buckets.size() - 1, new Bucket(last.idx(), last.range(), last.value() + bucket.value())); + return; + } + } + buckets.add(bucket); + } + + private static String range(Bucket from, Bucket to) { + if (from.range().startsWith("0...") || to.range().endsWith("...+Inf")) { + var start = from.range().substring(0, from.range().indexOf("...")); + var end = to.range().substring(to.range().indexOf("...") + 3); + return start + "..." + end; + } + return getRangeTagValue(from.idx(), to.idx()); + } + + private record Bucket(IdxOffset idx, String range, long value) {} + } +} diff --git a/telemetry/telemetry-common/src/main/java/io/koraframework/telemetry/common/TelemetryConfig.java b/telemetry/telemetry-common/src/main/java/io/koraframework/telemetry/common/TelemetryConfig.java index 9526539fa..f71304408 100644 --- a/telemetry/telemetry-common/src/main/java/io/koraframework/telemetry/common/TelemetryConfig.java +++ b/telemetry/telemetry-common/src/main/java/io/koraframework/telemetry/common/TelemetryConfig.java @@ -37,6 +37,12 @@ default Map attributes() { @ConfigMapper interface MetricsConfig { + enum MetricsMode { + SUMMARY, + SLO, + VM + } + Duration[] DEFAULT_SLO = new Duration[]{ Duration.ofMillis(1), Duration.ofMillis(10), @@ -58,6 +64,12 @@ default boolean enabled() { return false; } + default MetricsMode mode() { + return MetricsMode.SLO; + } + + VmConfig vm(); + default Duration[] slo() { return DEFAULT_SLO; } @@ -65,5 +77,21 @@ default Duration[] slo() { default Map tags() { return Map.of(); } + + @ConfigMapper + interface VmConfig { + + default Duration min() { + return Duration.ofMillis(1); + } + + default Duration max() { + return Duration.ofSeconds(90); + } + + default int buckets() { + return 16; + } + } } } From f7bb4f7c07d9399f7d1fb2172a082cedf2cd71dc Mon Sep 17 00:00:00 2001 From: Anton Kurako Date: Sat, 18 Jul 2026 23:04:32 +0300 Subject: [PATCH 2/2] Javadoc --- .../module/VictoriaMetricsHistogram.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java b/micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java index 73dfe70f3..833803caa 100644 --- a/micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java +++ b/micrometer/micrometer-module/src/main/java/io/koraframework/micrometer/module/VictoriaMetricsHistogram.java @@ -22,6 +22,37 @@ import java.util.concurrent.atomic.AtomicReferenceArray; import java.util.concurrent.atomic.DoubleAdder; +/** + * VictoriaMetrics-compatible histogram collector for Micrometer Prometheus registry. + *

+ * The implementation is inspired by VictoriaMetrics {@code vmrange} histogram format, + * the Go VictoriaMetrics metrics implementation, and the old Micrometer 1.12 + * {@code FixedBoundaryVictoriaMetricsHistogram} implementation. + *

+ * The collector keeps one registered Prometheus collector per metric name and stores a + * separate child for every unique Micrometer tag set. Recording is lock-free for existing + * children: the input value is converted to seconds, mapped to a VictoriaMetrics bucket by + * decimal exponent and mantissa, and increments the corresponding {@link AtomicLongArray} + * cell. The fixed grid has special buckets for {@code 0...0}, values below {@code 1e-9}, + * and values above {@code 1e18}; regular buckets split every decimal exponent from + * {@code 1e-9} to {@code 1e18} into {@code 9 * DECIMAL_MULTIPLIER} ranges. + *

+ * The builder-provided {@code min}, {@code max}, and {@code buckets} values affect only + * scrape output and cardinality, not the internal recording grid. During collection the + * child scans occupied VM buckets, folds everything below {@code min} into a single + * {@code 0...min} range, folds everything above {@code max} into {@code max...+Inf}, and + * leaves in-range buckets in their native {@code vmrange} form. If the resulting occupied + * ranges still exceed {@code buckets}, adjacent ranges are merged into at most + * {@code buckets} exported bucket series for that label set. + *

+ * Export uses Prometheus {@code untyped} series because VictoriaMetrics {@code vmrange} + * buckets are not Prometheus cumulative {@code le} histogram buckets. Bucket range merging + * affects only distribution precision in scrape output; {@code _count} and {@code _sum} + * are accumulated independently and remain exact. + * + * @see VictoriaMetrics histograms + * @see Improving histogram usability for Prometheus and Grafana + */ public final class VictoriaMetricsHistogram { private static final int E10_MIN = -9;