This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 60db87d07 UNOMI-1007: Align health check JSON with the 3.0 response
format (#937)
60db87d07 is described below
commit 60db87d075dcbd60fe15af8836529319d300604d
Author: Serge Huber <[email protected]>
AuthorDate: Sun Nov 1 07:12:22 2026 +0100
UNOMI-1007: Align health check JSON with the 3.0 response format (#937)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
extensions/healthcheck/README.md | 24 +++++--
.../unomi/healthcheck/HealthCheckService.java | 7 +-
.../healthcheck/servlet/HealthCheckServlet.java | 17 +++--
.../HealthCheckResponseJsonFormatTest.java | 81 ++++++++++++++++++++++
.../unomi/healthcheck/HealthCheckServiceTest.java | 30 ++++++++
.../org/apache/unomi/itests/HealthCheckIT.java | 47 ++++++++++---
manual/src/main/asciidoc/configuration.adoc | 24 +++++--
7 files changed, 200 insertions(+), 30 deletions(-)
diff --git a/extensions/healthcheck/README.md b/extensions/healthcheck/README.md
index defee637c..fe6c3a68d 100644
--- a/extensions/healthcheck/README.md
+++ b/extensions/healthcheck/README.md
@@ -42,15 +42,12 @@ All healthcheck can have a status :
Any subsystem health check uses the configured timeout (default 400 ms); a
check that does not answer in time is cancelled and returned as an error.
-Typical response to /health/check when unomi NOT started is :
+Typical response to `/health/check` when Unomi is not started yet
(Elasticsearch distribution).
+Items are sorted by `name` (same as Unomi 3.0). Empty `data` is omitted;
search backends may
+add cluster details when the probe answers:
```json
[
- {
- "name":"karaf",
- "status":"LIVE",
- "collectingTime":0
- },
{
"name":"cluster",
"status":"DOWN",
@@ -59,7 +56,17 @@ Typical response to /health/check when unomi NOT started is :
{
"name":"elasticsearch",
"status":"LIVE",
- "collectingTime":6
+ "collectingTime":6,
+ "data":{
+ "cluster_name":"elasticsearch",
+ "status":"green",
+ "number_of_nodes":1
+ }
+ },
+ {
+ "name":"karaf",
+ "status":"LIVE",
+ "collectingTime":0
},
{
"name":"persistence",
@@ -74,6 +81,9 @@ Typical response to /health/check when unomi NOT started is :
]
```
+Each object has `name`, `status` (`DOWN` | `UP` | `LIVE` | `ERROR`),
`collectingTime` (ms), and
+optional `data`. HTTP 200 only when every status is `LIVE`; otherwise 206.
+
## Configuration
Configuration is located in the file `etc/org.apache.unomi.healthcheck.cfg`
(installed by the Elasticsearch or OpenSearch healthcheck feature).
diff --git
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
index f3f7d25e9..43c0a5b37 100644
---
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
+++
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
@@ -32,6 +32,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -210,7 +211,11 @@ public class HealthCheckService {
return;
} catch (Exception e) {
LOGGER.error("Error while executing health check {}",
provider.name(), e);
- health.add(provider.timeout());
+ // Match provider-level errors (and 3.0's error field):
status ERROR with
+ // data.error. Do not report these as timeouts.
+ Throwable cause = e instanceof ExecutionException &&
e.getCause() != null ? e.getCause() : e;
+ String message = cause.getMessage() != null ?
cause.getMessage() : cause.getClass().getName();
+
health.add(HealthCheckResponse.named(provider.name()).error().withData("error",
message).build());
}
}
health.sort(Comparator.comparing(HealthCheckResponse::getName));
diff --git
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
index e1f5f3755..eb63f0634 100644
---
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
+++
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckServlet.java
@@ -32,6 +32,8 @@ import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
/**
@@ -74,15 +76,18 @@ public class HealthCheckServlet extends HttpServlet {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
- List<HealthCheckResponse> checks = service.check();
+ // Sort a mutable copy (3.0 sorted in the servlet). The service cache
is an
+ // unmodifiable list, so sorting it in place would fail.
+ List<HealthCheckResponse> checks = new ArrayList<>(service.check());
+ checks.sort(Comparator.comparing(HealthCheckResponse::getName));
response.getWriter().println(mapper.writeValueAsString(checks));
response.setContentType("application/json");
response.setHeader("Cache-Control", "no-cache");
- if (checks.stream().allMatch(HealthCheckResponse::isLive)) {
- response.setStatus(HttpServletResponse.SC_OK);
- } else {
- response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
- }
+ if (checks.stream().allMatch(HealthCheckResponse::isLive)) {
+ response.setStatus(HttpServletResponse.SC_OK);
+ } else {
+ response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
+ }
response.flushBuffer();
}
}
diff --git
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckResponseJsonFormatTest.java
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckResponseJsonFormatTest.java
new file mode 100644
index 000000000..5f8cf885e
--- /dev/null
+++
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckResponseJsonFormatTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.unomi.healthcheck;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Locks the JSON wire format of {@link HealthCheckResponse} to the 3.0 shape:
+ * {@code name}, {@code status} (enum name), {@code collectingTime}, and
optional {@code data}
+ * (omitted when empty).
+ */
+class HealthCheckResponseJsonFormatTest {
+
+ private ObjectMapper mapper;
+
+ @BeforeEach
+ void setUp() {
+ mapper = new ObjectMapper();
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
+ }
+
+ @Test
+ void liveWithoutDataOmitsDataField() throws Exception {
+ JsonNode node =
mapper.readTree(mapper.writeValueAsString(HealthCheckResponse.live("karaf")));
+ assertEquals("karaf", node.get("name").asText());
+ assertEquals("LIVE", node.get("status").asText());
+ assertTrue(node.get("collectingTime").isNumber());
+ assertFalse(node.has("data"));
+ assertEquals(3, node.size());
+ }
+
+ @Test
+ void timeoutUsesErrorCauseLike30() throws Exception {
+ HealthCheckProvider provider = new HealthCheckProvider() {
+ @Override
+ public String name() {
+ return "persistence";
+ }
+
+ @Override
+ public HealthCheckResponse execute() {
+ return HealthCheckResponse.live(name());
+ }
+ };
+ JsonNode node =
mapper.readTree(mapper.writeValueAsString(provider.timeout()));
+ assertEquals("persistence", node.get("name").asText());
+ assertEquals("ERROR", node.get("status").asText());
+ assertEquals("timeout", node.get("data").get("error.cause").asText());
+ assertFalse(node.get("data").has("error"));
+ }
+
+ @Test
+ void errorUsesErrorFieldLikeProviderFailures() throws Exception {
+ JsonNode node = mapper.readTree(mapper.writeValueAsString(
+
HealthCheckResponse.named("elasticsearch").error().withData("error",
"connection refused").build()));
+ assertEquals("ERROR", node.get("status").asText());
+ assertEquals("connection refused",
node.get("data").get("error").asText());
+ }
+}
diff --git
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
index 0fe4e0bb3..f3fe66610 100644
---
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
+++
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/HealthCheckServiceTest.java
@@ -32,6 +32,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -99,6 +100,35 @@ class HealthCheckServiceTest {
assertTrue(response(health, "postgresql").isLive());
}
+ @Test
+ void throwingProviderIsReportedAsErrorWithItsMessage() {
+ service.bind(new TestProvider("fast", () ->
HealthCheckResponse.live("fast")));
+ service.bind(new TestProvider("blocked", () -> {
+ throw new IllegalStateException("boom");
+ }));
+
+ List<HealthCheckResponse> health = service.check();
+
+ HealthCheckResponse blocked = response(health, "blocked");
+ assertTrue(blocked.isError());
+ assertEquals("boom", blocked.getData().get("error"));
+ assertFalse(blocked.getData().containsKey("error.cause"));
+ }
+
+ @Test
+ void resultsAreSortedByName() throws Exception {
+ HealthCheckConfig config = new HealthCheckConfig();
+ config.setEnabledProviders(List.of("postgresql", "elasticsearch"));
+ config.setTimeout(TIMEOUT);
+ service.setConfig(config);
+ service.bind(new TestProvider("postgresql", () ->
HealthCheckResponse.live("postgresql")));
+ service.bind(new TestProvider("elasticsearch", () ->
HealthCheckResponse.live("elasticsearch")));
+
+ List<String> names =
service.check().stream().map(HealthCheckResponse::getName).toList();
+
+ assertEquals(List.of("elasticsearch", "karaf", "postgresql"), names);
+ }
+
@Test
void secondCallWithinOneSecondReturnsTheCachedResults() {
TestProvider fast = new TestProvider("fast", () ->
HealthCheckResponse.live("fast"));
diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
index 6fcf81798..413d711a6 100644
--- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
@@ -65,7 +65,8 @@ public class HealthCheckIT extends BaseIT {
@Test
public void testHealthCheck() {
try {
- List<HealthCheckResponse> response = get(HEALTHCHECK_ENDPOINT, new
TypeReference<>() {});
+ // When every probe is LIVE, clients (and HA proxies) expect HTTP
200 — same as Unomi 3.0.
+ List<HealthCheckResponse> response = get(HEALTHCHECK_ENDPOINT, new
TypeReference<>() {}, 200);
LOGGER.info("configured persistence provider: {}",
getPersistenceBackend().providerId());
LOGGER.info("health check response: {}", response);
Assert.assertNotNull(response);
@@ -87,11 +88,14 @@ public class HealthCheckIT extends BaseIT {
List<Future<List<HealthCheckResponse>>> futures = new
ArrayList<>();
for (int i = 0; i < NB_ITERATIONS; i++) {
for (int j = 0; j < NB_THREADS; j++) {
- Future<List<HealthCheckResponse>> future =
executorService.submit(() -> get(HEALTHCHECK_ENDPOINT, new TypeReference<>()
{}));
+ // UNOMI-924: concurrent calls must not fail with HTTP
500; accept 200 or 206.
+ Future<List<HealthCheckResponse>> future =
executorService.submit(
+ () -> get(HEALTHCHECK_ENDPOINT, new
TypeReference<>() {}, 200, 206));
futures.add(future);
}
for (Future<List<HealthCheckResponse>> future : futures) {
List<HealthCheckResponse> health = future.get(10,
TimeUnit.SECONDS);
+ Assert.assertNotNull("Concurrent health check returned
null (unexpected HTTP status)", health);
assertHealthCheckLive(health);
}
Thread.sleep(10);
@@ -110,6 +114,7 @@ public class HealthCheckIT extends BaseIT {
private void assertHealthCheckLive(List<HealthCheckResponse> response) {
Assert.assertNotNull(response);
+ assertResponseFormatLike30(response);
Assert.assertTrue(response.stream().anyMatch(r ->
r.getName().equals("karaf") && r.getStatus() ==
HealthCheckResponse.Status.LIVE));
Assert.assertTrue(response.stream().anyMatch(r ->
r.getName().equals("unomi") && r.getStatus() ==
HealthCheckResponse.Status.LIVE));
Assert.assertTrue(response.stream().anyMatch(r ->
r.getName().equals("persistence") && r.getStatus() ==
HealthCheckResponse.Status.LIVE));
@@ -136,6 +141,24 @@ public class HealthCheckIT extends BaseIT {
}
}
+ /**
+ * Wire format shared with Unomi 3.0: each item has {@code name}, {@code
status},
+ * {@code collectingTime}; optional {@code data}; items sorted by name.
+ */
+ private static void assertResponseFormatLike30(List<HealthCheckResponse>
response) {
+ List<String> names = new ArrayList<>();
+ for (HealthCheckResponse item : response) {
+ Assert.assertNotNull("Health check item missing name: " + item,
item.getName());
+ Assert.assertFalse("Health check name must not be blank",
item.getName().isBlank());
+ Assert.assertNotNull("Health check item missing status: " + item,
item.getStatus());
+ Assert.assertTrue("collectingTime must be >= 0: " + item,
item.getCollectingTime() >= 0);
+ names.add(item.getName());
+ }
+ List<String> sorted = new ArrayList<>(names);
+ sorted.sort(String::compareTo);
+ Assert.assertEquals("Health check items must be sorted by name (Unomi
3.0 servlet behaviour)", sorted, names);
+ }
+
@Test
public void testHealthCheck_wrongPasswordRejected() throws Exception {
final HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT));
@@ -165,7 +188,7 @@ public class HealthCheckIT extends BaseIT {
try {
updateHealthCheckConfiguration(original,
Map.of("healthcheck.providers", "unomi,persistence,notInstalled"));
keepTrying("Health check should only report karaf and the
configured providers",
- () -> probeNames(get(HEALTHCHECK_ENDPOINT, new
TypeReference<List<HealthCheckResponse>>() {})),
+ () -> probeNames(get(HEALTHCHECK_ENDPOINT, new
TypeReference<List<HealthCheckResponse>>() {}, 200, 206)),
names -> Set.of("karaf", "unomi",
"persistence").equals(names),
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
Assert.assertEquals(401, healthCheckStatus(AuthType.NONE, null));
@@ -199,7 +222,7 @@ public class HealthCheckIT extends BaseIT {
private void restoreHealthCheckConfiguration(Dictionary<String, Object>
original) throws IOException, InterruptedException {
configurationAdmin.getConfiguration(HEALTHCHECK_PID,
null).update(original);
keepTrying("Health check should be back to its original configuration",
- () -> get(HEALTHCHECK_ENDPOINT, new
TypeReference<List<HealthCheckResponse>>() {}),
+ () -> get(HEALTHCHECK_ENDPOINT, new
TypeReference<List<HealthCheckResponse>>() {}, 200),
response -> {
try {
assertHealthCheckLive(response);
@@ -228,16 +251,22 @@ public class HealthCheckIT extends BaseIT {
return response == null ? null :
response.stream().map(HealthCheckResponse::getName).collect(Collectors.toSet());
}
- protected <T> T get(final String url, TypeReference<T> typeReference) {
+ protected <T> T get(final String url, TypeReference<T> typeReference,
int... acceptedStatuses) {
+ int[] statuses = (acceptedStatuses == null || acceptedStatuses.length
== 0)
+ ? new int[] { 200, 206 }
+ : acceptedStatuses;
CloseableHttpResponse response = null;
try {
final HttpGet httpGet = new HttpGet(getFullUrl(url));
response = executeHttpRequest(httpGet, AuthType.CUSTOM_BASIC,
HEALTHCHECK_AUTH_USER_NAME, HEALTHCHECK_AUTH_PASSWORD);
- if (response.getStatusLine().getStatusCode() == 200 ||
response.getStatusLine().getStatusCode() == 206) {
- return
getObjectMapper().readValue(response.getEntity().getContent(), typeReference);
- } else {
- return null;
+ int code = response.getStatusLine().getStatusCode();
+ for (int accepted : statuses) {
+ if (code == accepted) {
+ return
getObjectMapper().readValue(response.getEntity().getContent(), typeReference);
+ }
}
+ LOGGER.error("Unexpected health check HTTP status {} (accepted
{})", code, java.util.Arrays.toString(statuses));
+ return null;
} catch (Exception e) {
LOGGER.error("Error performing GET request with url {}", url, e);
} finally {
diff --git a/manual/src/main/asciidoc/configuration.adoc
b/manual/src/main/asciidoc/configuration.adoc
index a5803449e..778f71627 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -1997,16 +1997,13 @@ All healthcheck can have a status :
Any subsystem health check uses the configured timeout (default 400 ms); a
check that does not answer in time is cancelled and returned as an error.
-Typical response to /health/check when unomi NOT started is :
+Typical response to `/health/check` when Unomi is not started yet
(Elasticsearch distribution).
+Items are sorted by `name` (same as Unomi 3.0). Empty `data` is omitted;
search backends may
+add cluster details when the probe answers:
[source,json]
----
[
- {
- "name":"karaf",
- "status":"LIVE",
- "collectingTime":0
- },
{
"name":"cluster",
"status":"DOWN",
@@ -2015,7 +2012,17 @@ Typical response to /health/check when unomi NOT started
is :
{
"name":"elasticsearch",
"status":"LIVE",
- "collectingTime":6
+ "collectingTime":6,
+ "data":{
+ "cluster_name":"elasticsearch",
+ "status":"green",
+ "number_of_nodes":1
+ }
+ },
+ {
+ "name":"karaf",
+ "status":"LIVE",
+ "collectingTime":0
},
{
"name":"persistence",
@@ -2030,6 +2037,9 @@ Typical response to /health/check when unomi NOT started
is :
]
----
+Each object has `name`, `status` (`DOWN` | `UP` | `LIVE` | `ERROR`),
`collectingTime` (milliseconds), and
+optional `data`. HTTP 200 only when every status is `LIVE`; otherwise 206.
+
Existing health check can be extended by adding specific provider in the
extension. A provider is a class that implements the HealthCheckProvider
interface.
[source,java]