This is an automated email from the ASF dual-hosted git repository. squakez pushed a commit to branch feat/CAMEL-24356 in repository https://gitbox.apache.org/repos/asf/camel.git
commit e149a2e35453357a1579a076a061bac89e267742 Author: Pasquale Congiusti <[email protected]> AuthorDate: Mon Aug 10 14:48:46 2026 +0200 feat(components): micrometer supports prometheus format Add a conversion to support the feature from this component instead of the prometheus one Ref CAMEL-24356 --- .../micrometer/json/AbstractMicrometerService.java | 152 ++++++++++++-- ...tractMicrometerServicePrometheusFormatTest.java | 225 +++++++++++++++++++++ 2 files changed, 364 insertions(+), 13 deletions(-) diff --git a/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/json/AbstractMicrometerService.java b/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/json/AbstractMicrometerService.java index fe1c5d942163..65c306bed186 100644 --- a/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/json/AbstractMicrometerService.java +++ b/components/camel-micrometer/src/main/java/org/apache/camel/component/micrometer/json/AbstractMicrometerService.java @@ -16,7 +16,9 @@ */ package org.apache.camel.component.micrometer.json; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -31,6 +33,7 @@ import io.micrometer.core.instrument.DistributionSummary; import io.micrometer.core.instrument.FunctionCounter; import io.micrometer.core.instrument.FunctionTimer; import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.LongTaskTimer; import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; @@ -56,6 +59,7 @@ public class AbstractMicrometerService extends ServiceSupport { private boolean prettyPrint = true; private boolean skipCamelInfo = false; private boolean logMetricsOnShutdown = false; + private String logMetricsOnShutdownFormat = "json"; private String logMetricsOnShutdownFilters[]; private TimeUnit durationUnit = TimeUnit.MILLISECONDS; private Iterable<Tag> matchingTags = Tags.empty(); @@ -103,6 +107,22 @@ public class AbstractMicrometerService extends ServiceSupport { this.logMetricsOnShutdown = logMetricsOnShutdown; } + /** + * Returns the output format used when logging metrics on shutdown. Accepted values are {@code "json"} (default) and + * {@code "prometheus"}. + */ + public String getLogMetricsOnShutdownFormat() { + return logMetricsOnShutdownFormat; + } + + /** + * Sets the output format used when logging metrics on shutdown. Accepted values are {@code "json"} (default) and + * {@code "prometheus"}. + */ + public void setLogMetricsOnShutdownFormat(String logMetricsOnShutdownFormat) { + this.logMetricsOnShutdownFormat = logMetricsOnShutdownFormat; + } + public String[] getLogMetricsOnShutdownFilters() { return logMetricsOnShutdownFilters; } @@ -243,19 +263,125 @@ public class AbstractMicrometerService extends ServiceSupport { } void logMetricsOnShutdown(String... filters) { - meterRegistry.getMeters().stream() - .filter(m -> AbstractMicrometerService.matchesFilter(m.getId().getName(), filters)) - .map(AbstractMicrometerService::convertMeterToMap) - .forEach(logEntry -> { - try { - // we include a start and end tag to make sure the - // scraper can more easily identify the metric content. - String metric = "#METRIC-START#" + mapper.writeValueAsString(logEntry) + "#METRIC-END#"; - LOG.info(metric); - } catch (Exception e) { - LOG.error("Error logging metric " + logEntry.get("name"), e); - } - }); + if ("prometheus".equalsIgnoreCase(logMetricsOnShutdownFormat)) { + meterRegistry.getMeters().stream() + .filter(m -> AbstractMicrometerService.matchesFilter(m.getId().getName(), filters)) + .forEach(this::logMetricsAsPrometheus); + } else { + meterRegistry.getMeters().stream() + .filter(m -> AbstractMicrometerService.matchesFilter(m.getId().getName(), filters)) + .map(AbstractMicrometerService::convertMeterToMap) + .forEach(this::logMetricsAsJson); + } + } + + private void logMetricsAsJson(Map<String, Object> logEntry) { + try { + // we include a start and end tag to make sure the + // scraper can more easily identify the metric content. + String metric = "#METRIC-START#" + mapper.writeValueAsString(logEntry) + "#METRIC-END#"; + LOG.info(metric); + } catch (Exception e) { + LOG.error("Error logging metric {}", logEntry.get("name"), e); + } + } + + private void logMetricsAsPrometheus(Meter meter) { + for (String line : convertMeterToPrometheusLines(meter)) { + LOG.info("#METRIC-START#{}#METRIC-END#", line); + } + } + + /** + * Converts a single {@link Meter} into one or more Prometheus text-exposition lines. + * <p> + * The output follows the Prometheus text format specification: + * + * <pre> + * # HELP <name> <description> + * # TYPE <name> <type> + * <name>{labels} <value> + * </pre> + * + * Metric names use underscores in place of dots/hyphens as required by Prometheus naming rules. + */ + static List<String> convertMeterToPrometheusLines(Meter meter) { + String rawName = meter.getId().getName(); + String promName = rawName.replace('.', '_').replace('-', '_'); + String description = meter.getId().getDescription() != null ? meter.getId().getDescription() : promName; + String labels = buildPrometheusLabels(meter.getId().getTags()); + + List<String> lines = new ArrayList<>(); + + if (meter instanceof Gauge g) { + lines.add("# HELP " + promName + " " + description); + lines.add("# TYPE " + promName + " gauge"); + lines.add(promName + labels + " " + formatPrometheusDouble(g.value())); + } else if (meter instanceof Counter c) { + lines.add("# HELP " + promName + "_total " + description); + lines.add("# TYPE " + promName + "_total counter"); + lines.add(promName + "_total" + labels + " " + formatPrometheusDouble(c.count())); + } else if (meter instanceof Timer t) { + lines.add("# HELP " + promName + "_seconds " + description); + lines.add("# TYPE " + promName + "_seconds summary"); + lines.add(promName + "_seconds_count" + labels + " " + t.count()); + lines.add(promName + "_seconds_sum" + labels + " " + formatPrometheusDouble(t.totalTime(TimeUnit.SECONDS))); + lines.add(promName + "_seconds_max" + labels + " " + formatPrometheusDouble(t.max(TimeUnit.SECONDS))); + } else if (meter instanceof DistributionSummary ds) { + lines.add("# HELP " + promName + " " + description); + lines.add("# TYPE " + promName + " summary"); + lines.add(promName + "_count" + labels + " " + ds.count()); + lines.add(promName + "_sum" + labels + " " + formatPrometheusDouble(ds.totalAmount())); + lines.add(promName + "_max" + labels + " " + formatPrometheusDouble(ds.max())); + } else if (meter instanceof FunctionCounter fc) { + lines.add("# HELP " + promName + "_total " + description); + lines.add("# TYPE " + promName + "_total counter"); + lines.add(promName + "_total" + labels + " " + formatPrometheusDouble(fc.count())); + } else if (meter instanceof FunctionTimer ft) { + lines.add("# HELP " + promName + "_seconds " + description); + lines.add("# TYPE " + promName + "_seconds summary"); + lines.add(promName + "_seconds_count" + labels + " " + formatPrometheusDouble(ft.count())); + lines.add(promName + "_seconds_sum" + labels + " " + formatPrometheusDouble(ft.totalTime(TimeUnit.SECONDS))); + } else if (meter instanceof LongTaskTimer ltt) { + lines.add("# HELP " + promName + "_active_seconds " + description); + lines.add("# TYPE " + promName + "_active_seconds gauge"); + lines.add(promName + "_active_seconds_active" + labels + " " + ltt.activeTasks()); + lines.add(promName + "_active_seconds_duration" + labels + " " + + formatPrometheusDouble(ltt.duration(TimeUnit.SECONDS))); + lines.add(promName + "_active_seconds_max" + labels + " " + + formatPrometheusDouble(ltt.max(TimeUnit.SECONDS))); + } else { + // Generic fallback for unknown meter types + lines.add("# HELP " + promName + " " + description); + lines.add("# TYPE " + promName + " untyped"); + lines.add(promName + labels + " 0"); + } + + return lines; + } + + private static String buildPrometheusLabels(Iterable<Tag> tags) { + StringBuilder sb = new StringBuilder(); + for (Tag tag : tags) { + if (sb.length() > 0) { + sb.append(","); + } + sb.append(tag.getKey().replace('.', '_').replace('-', '_')); + sb.append("=\""); + sb.append(tag.getValue().replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")); + sb.append("\""); + } + return sb.length() == 0 ? "" : "{" + sb + "}"; + } + + private static String formatPrometheusDouble(double value) { + if (Double.isNaN(value)) { + return "NaN"; + } + if (Double.isInfinite(value)) { + return value > 0 ? "+Inf" : "-Inf"; + } + return Double.toString(value); } // This method does a best effort attempt to recover information about versioning of the runtime. diff --git a/components/camel-micrometer/src/test/java/org/apache/camel/component/micrometer/json/AbstractMicrometerServicePrometheusFormatTest.java b/components/camel-micrometer/src/test/java/org/apache/camel/component/micrometer/json/AbstractMicrometerServicePrometheusFormatTest.java new file mode 100644 index 000000000000..a4a157c69ac7 --- /dev/null +++ b/components/camel-micrometer/src/test/java/org/apache/camel/component/micrometer/json/AbstractMicrometerServicePrometheusFormatTest.java @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.micrometer.json; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.*; +import io.micrometer.core.instrument.LongTaskTimer; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AbstractMicrometerServicePrometheusFormatTest { + + // ── field default ──────────────────────────────────────────────────────── + + @Test + void defaultFormatIsJson() { + // verify default without instantiating a full service context + // by exercising the getter through a concrete subclass stub + AbstractMicrometerService svc = new AbstractMicrometerService() { + }; + assertEquals("json", svc.getLogMetricsOnShutdownFormat()); + } + + @Test + void setFormatPrometheusRoundTrips() { + AbstractMicrometerService svc = new AbstractMicrometerService() { + }; + svc.setLogMetricsOnShutdownFormat("prometheus"); + assertEquals("prometheus", svc.getLogMetricsOnShutdownFormat()); + } + + // ── Gauge ──────────────────────────────────────────────────────────────── + + @Test + void gaugeProducesHelpTypeAndValue() { + Gauge gauge = mock(Gauge.class); + Meter.Id id = new Meter.Id("app.info", Tags.empty(), null, "Application info", Meter.Type.GAUGE); + when(gauge.getId()).thenReturn(id); + when(gauge.value()).thenReturn(1.0); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(gauge); + + assertEquals("# HELP app_info Application info", lines.get(0)); + assertEquals("# TYPE app_info gauge", lines.get(1)); + assertEquals("app_info 1.0", lines.get(2)); + assertEquals(3, lines.size()); + } + + @Test + void gaugeNaNIsRenderedAsNaN() { + Gauge gauge = mock(Gauge.class); + Meter.Id id = new Meter.Id("my.gauge", Tags.empty(), null, null, Meter.Type.GAUGE); + when(gauge.getId()).thenReturn(id); + when(gauge.value()).thenReturn(Double.NaN); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(gauge); + + assertTrue(lines.get(2).endsWith(" NaN")); + } + + // ── Counter ────────────────────────────────────────────────────────────── + + @Test + void counterProducesTotalSuffix() { + Counter counter = mock(Counter.class); + Meter.Id id = new Meter.Id("camel.exchanges.completed", Tags.empty(), null, null, Meter.Type.COUNTER); + when(counter.getId()).thenReturn(id); + when(counter.count()).thenReturn(42.0); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(counter); + + assertEquals("# HELP camel_exchanges_completed_total camel_exchanges_completed", lines.get(0)); + assertEquals("# TYPE camel_exchanges_completed_total counter", lines.get(1)); + assertEquals("camel_exchanges_completed_total 42.0", lines.get(2)); + assertEquals(3, lines.size()); + } + + // ── Timer ──────────────────────────────────────────────────────────────── + + @Test + void timerProducesCountSumMaxInSeconds() { + Timer timer = mock(Timer.class); + Meter.Id id = new Meter.Id("camel.route.duration", Tags.empty(), null, "Route duration", Meter.Type.TIMER); + when(timer.getId()).thenReturn(id); + when(timer.count()).thenReturn(10L); + when(timer.totalTime(TimeUnit.SECONDS)).thenReturn(2.5); + when(timer.max(TimeUnit.SECONDS)).thenReturn(0.8); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(timer); + + assertEquals("# HELP camel_route_duration_seconds Route duration", lines.get(0)); + assertEquals("# TYPE camel_route_duration_seconds summary", lines.get(1)); + assertEquals("camel_route_duration_seconds_count 10", lines.get(2)); + assertEquals("camel_route_duration_seconds_sum 2.5", lines.get(3)); + assertEquals("camel_route_duration_seconds_max 0.8", lines.get(4)); + assertEquals(5, lines.size()); + } + + // ── DistributionSummary ─────────────────────────────────────────────────── + + @Test + void distributionSummaryProducesCountSumMax() { + DistributionSummary ds = mock(DistributionSummary.class); + Meter.Id id = new Meter.Id("payload.size", Tags.empty(), null, null, Meter.Type.DISTRIBUTION_SUMMARY); + when(ds.getId()).thenReturn(id); + when(ds.count()).thenReturn(5L); + when(ds.totalAmount()).thenReturn(500.0); + when(ds.max()).thenReturn(200.0); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(ds); + + assertEquals("# TYPE payload_size summary", lines.get(1)); + assertEquals("payload_size_count 5", lines.get(2)); + assertEquals("payload_size_sum 500.0", lines.get(3)); + assertEquals("payload_size_max 200.0", lines.get(4)); + } + + // ── FunctionCounter ─────────────────────────────────────────────────────── + + @Test + void functionCounterProducesTotalSuffix() { + FunctionCounter fc = mock(FunctionCounter.class); + Meter.Id id = new Meter.Id("my.fc", Tags.empty(), null, null, Meter.Type.COUNTER); + when(fc.getId()).thenReturn(id); + when(fc.count()).thenReturn(7.0); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(fc); + + assertTrue(lines.stream().anyMatch(l -> l.equals("# TYPE my_fc_total counter"))); + assertTrue(lines.stream().anyMatch(l -> l.equals("my_fc_total 7.0"))); + } + + // ── LongTaskTimer ───────────────────────────────────────────────────────── + + @Test + void longTaskTimerProducesActiveTasksDurationMax() { + LongTaskTimer ltt = mock(LongTaskTimer.class); + Meter.Id id = new Meter.Id("camel.long.task", Tags.empty(), null, "Long running task", Meter.Type.LONG_TASK_TIMER); + when(ltt.getId()).thenReturn(id); + when(ltt.activeTasks()).thenReturn(3); + when(ltt.duration(TimeUnit.SECONDS)).thenReturn(12.5); + when(ltt.max(TimeUnit.SECONDS)).thenReturn(6.0); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(ltt); + + assertEquals("# HELP camel_long_task_active_seconds Long running task", lines.get(0)); + assertEquals("# TYPE camel_long_task_active_seconds gauge", lines.get(1)); + assertEquals("camel_long_task_active_seconds_active 3", lines.get(2)); + assertEquals("camel_long_task_active_seconds_duration 12.5", lines.get(3)); + assertEquals("camel_long_task_active_seconds_max 6.0", lines.get(4)); + assertEquals(5, lines.size()); + } + + // ── FunctionTimer ───────────────────────────────────────────────────────── + + @Test + void functionTimerProducesCountAndSum() { + FunctionTimer ft = mock(FunctionTimer.class); + Meter.Id id = new Meter.Id("my.ft", Tags.empty(), null, null, Meter.Type.TIMER); + when(ft.getId()).thenReturn(id); + when(ft.count()).thenReturn(3.0); + when(ft.totalTime(TimeUnit.SECONDS)).thenReturn(1.5); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(ft); + + assertTrue(lines.stream().anyMatch(l -> l.equals("my_ft_seconds_count 3.0"))); + assertTrue(lines.stream().anyMatch(l -> l.equals("my_ft_seconds_sum 1.5"))); + } + + // ── label encoding ──────────────────────────────────────────────────────── + + @Test + void tagsAreRenderedAsPrometheusLabels() { + Gauge gauge = mock(Gauge.class); + Meter.Id id = new Meter.Id( + "app.info", + Tags.of("camel.version", "4.0.0", "camel.context", "my-ctx"), + null, null, Meter.Type.GAUGE); + when(gauge.getId()).thenReturn(id); + when(gauge.value()).thenReturn(0.0); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(gauge); + + String valueLine = lines.get(2); + assertTrue(valueLine.startsWith("app_info{"), "value line should carry labels"); + assertTrue(valueLine.contains("camel_version=\"4.0.0\"")); + assertTrue(valueLine.contains("camel_context=\"my-ctx\"")); + } + + @Test + void specialCharsInTagValuesAreEscaped() { + Gauge gauge = mock(Gauge.class); + Meter.Id id = new Meter.Id( + "my.gauge", + Tags.of("path", "say\\\"hello\"\nworld"), + null, null, Meter.Type.GAUGE); + when(gauge.getId()).thenReturn(id); + when(gauge.value()).thenReturn(1.0); + + List<String> lines = AbstractMicrometerService.convertMeterToPrometheusLines(gauge); + String valueLine = lines.get(2); + + assertTrue(valueLine.contains("\\\\"), "backslash must be escaped"); + assertTrue(valueLine.contains("\\\""), "quote must be escaped"); + assertTrue(valueLine.contains("\\n"), "newline must be escaped"); + } +}
