From 06a43a1c1929d6bd232327e4a6ce08e962990f00 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 2 Sep 2026 15:28:03 +0200 Subject: [PATCH] CAMEL-24512: camel-spring-boot - keep each failing health check's error message CamelHealthCheckIndicator applies every health check result to the same Health.Builder, and error.message was written only as a flat top-level detail. Health.Builder.withDetail is a Map.put, so with more than one DOWN check the last result overwrote the messages of all the earlier ones. The message is now also written into the check-scoped .data map, alongside the other per-check entries, so each failing check keeps its own. The top-level key is left in place for compatibility. The diff is deliberately confined to error.message: PR #1929 (CAMEL-24592) changes the stack-trace gating in the same method. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0165HC1XCB3h6mMii6wbZneG Signed-off-by: Andrea Cosentino --- .../actuate/health/CamelHealthHelper.java | 4 + ...CamelHealthHelperMultipleFailuresTest.java | 86 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelperMultipleFailuresTest.java diff --git a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelper.java b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelper.java index c6da07f0aded..5e1dec8af4e0 100644 --- a/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelper.java +++ b/core/camel-spring-boot/src/main/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelper.java @@ -61,7 +61,11 @@ public static void applyHealthDetail(Health.Builder builder, HealthCheck.Result result.getError().ifPresent(error -> { if (error.getMessage() != null) { + // the top-level key is kept for compatibility, but it is a single slot on a builder shared + // by every check, so with more than one DOWN check the last one wins; the check-scoped copy + // is the one that stays addressable per check builder.withDetail("error.message", error.getMessage()); + data.put("error.message", error.getMessage()); } final StringWriter stackTraceWriter = new StringWriter(); try (final PrintWriter pw = new PrintWriter(stackTraceWriter, true)) { diff --git a/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelperMultipleFailuresTest.java b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelperMultipleFailuresTest.java new file mode 100644 index 000000000000..bb78f62a4e25 --- /dev/null +++ b/core/camel-spring-boot/src/test/java/org/apache/camel/spring/boot/actuate/health/CamelHealthHelperMultipleFailuresTest.java @@ -0,0 +1,86 @@ +/* + * 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.spring.boot.actuate.health; + +import org.apache.camel.health.HealthCheck; +import org.apache.camel.health.HealthCheckResultBuilder; +import org.apache.camel.impl.health.AbstractHealthCheck; +import org.junit.jupiter.api.Test; +import org.springframework.boot.health.contributor.Health; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * The indicator applies every health check result to one {@link Health.Builder}, so a detail written at the top level + * is a single slot shared by all of them. Each failing check has to keep its own message addressable. + */ +public class CamelHealthHelperMultipleFailuresTest { + + private static final class TestHealthCheck extends AbstractHealthCheck { + private TestHealthCheck(String id) { + super("test", id); + } + + @Override + protected void doCall(HealthCheckResultBuilder builder, Map options) { + builder.down(); + } + } + + private static HealthCheck.Result downResult(String id, Throwable error) { + return HealthCheckResultBuilder.on(new TestHealthCheck(id)).down().error(error).build(); + } + + @SuppressWarnings("unchecked") + private static Map checkData(Health health, String id) { + Object data = health.getDetails().get(id + ".data"); + assertNotNull(data, "expected per-check data for " + id + ", details were: " + health.getDetails()); + assertInstanceOf(Map.class, data); + return (Map) data; + } + + @Test + public void eachFailingCheckKeepsItsOwnMessage() { + Health.Builder builder = new Health.Builder(); + + CamelHealthHelper.applyHealthDetail(builder, + downResult("first", new IllegalArgumentException("first-message")), "default"); + CamelHealthHelper.applyHealthDetail(builder, + downResult("second", new IllegalStateException("second-message")), "default"); + + Health health = builder.build(); + + assertEquals("first-message", checkData(health, "first").get("error.message"), + "the first check's message must survive a later failing check"); + assertEquals("second-message", checkData(health, "second").get("error.message")); + } + + @Test + public void topLevelErrorMessageIsStillReported() { + Health.Builder builder = new Health.Builder(); + + CamelHealthHelper.applyHealthDetail(builder, + downResult("only", new IllegalStateException("the-message")), "default"); + + assertEquals("the-message", builder.build().getDetails().get("error.message"), + "the top-level key is kept for compatibility"); + } +}