From 5f3892210c206417938cd6cf46d4db40a32705c7 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 5 Oct 2026 17:54:42 +0000 Subject: [PATCH 1/6] SOLR-15823: Changelog entry and reference guide note for the nodes parameter --- .../deployment-guide/pages/configuring-logging.adoc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc index dfdf83c97b2b..2db5276470bf 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc @@ -116,6 +116,15 @@ curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all" -H 'Conten ' ---- +[source,bash] +---- +curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all" -H 'Content-Type: application/json' -d ' + [ + {"logger": "root", "level": "WARN"} + ] +' +---- + === Choosing Log Level at Startup You can temporarily choose a different logging level as you start Solr. From 90920c357dfd0fc22c8b6d32ecae38bb45efdbb7 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 5 Oct 2026 22:19:38 +0000 Subject: [PATCH 2/6] SOLR-15823: Add 'nodes' broadcast to the V2 logging levels GET endpoint --- .../client/api/endpoint/NodeLoggingApis.java | 6 +- .../solr/handler/admin/LoggingHandler.java | 2 +- .../solr/handler/admin/api/NodeLogging.java | 27 +++-- .../handler/admin/api/NodeLoggingAPITest.java | 34 +++++- .../api/NodeLoggingNodesSolrCloudTest.java | 106 +++++++++++++++++- 5 files changed, 160 insertions(+), 15 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java index 06d28017a5f0..ad00dc695bcc 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java @@ -35,8 +35,12 @@ public interface NodeLoggingApis { @Path("/levels") @Operation( summary = "List all log-levels for the target node.", + description = + "If the 'nodes' parameter is provided, the listing is instead collected from " + + "each of the named nodes (or from every live node, if 'nodes' is 'all'), and " + + "the response reports the per-node results.", tags = {"logging"}) - ListLevelsResponse listAllLoggersAndLevels(); + ListLevelsResponse listAllLoggersAndLevels(@QueryParam("nodes") String nodes); @PUT @Path("/levels") diff --git a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java index deaa364def91..87a1c88c16fe 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java @@ -88,7 +88,7 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw } squashV2Response(rsp, loggingApi.fetchLocalLogMessages(time)); } else { - squashV2Response(rsp, loggingApi.listAllLoggersAndLevels()); + squashV2Response(rsp, loggingApi.listAllLoggersAndLevels(null)); } rsp.setHttpCaching(false); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java index 2c7a582b53b3..4745d5adae9a 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java @@ -36,6 +36,7 @@ import org.apache.solr.client.api.model.LogMessagesResponse; import org.apache.solr.client.api.model.LoggingResponse; import org.apache.solr.client.api.model.SetThresholdRequestBody; +import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.request.LoggingApi; import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.SolrException; @@ -68,10 +69,17 @@ public NodeLogging(CoreContainer coreContainer) { @Override @PermissionName(CONFIG_READ_PERM) - public ListLevelsResponse listAllLoggersAndLevels() { + public ListLevelsResponse listAllLoggersAndLevels(String nodes) { ensureLogWatcherEnabled(); final ListLevelsResponse response = instantiateLoggingResponse(ListLevelsResponse.class); + if (nodes != null && !nodes.isEmpty()) { + final var req = new LoggingApi.ListAllLoggersAndLevels(); + req.setNodes(nodes); + proxyToNodes(response, req); + return response; + } + response.levels = watcher.getAllLevels(); final List loggerInfo = @@ -96,7 +104,10 @@ public LoggingResponse modifyLocalLogLevel(String nodes, List re } if (nodes != null && !nodes.isEmpty()) { - proxyLevelChangesToNodes(response, nodes, requestBody); + final var req = new LoggingApi.ModifyLocalLogLevel(); + requestBody.forEach(req::addLogLevelChange); + req.setNodes(nodes); + proxyToNodes(response, req); return response; } @@ -116,24 +127,20 @@ public LoggingResponse modifyLocalLogLevel(String nodes, List re * responding after that point (timeout, error, or leaving the live set mid-request) is instead * reported via {@link RemoteRequestProxy#getFailedNodes()}. */ - private void proxyLevelChangesToNodes( - LoggingResponse response, String nodes, List requestBody) { + private void proxyToNodes(T response, SolrRequest request) { if (coreContainer == null || coreContainer.getZkController() == null) { throw new SolrException( BAD_REQUEST, "The 'nodes' parameter is only supported in SolrCloud mode"); } try { - final var req = new LoggingApi.ModifyLocalLogLevel(); - requestBody.forEach(req::addLogLevelChange); - req.setNodes(nodes); final var reqProxy = - new V2SolrRequestBasedProxy(coreContainer, req) { + new V2SolrRequestBasedProxy(coreContainer, request) { @Override - public void processTypedProxiedResponse( - String nodeName, LoggingResponse proxiedResponse) { + public void processTypedProxiedResponse(String nodeName, T proxiedResponse) { response.remoteNodeData.put(nodeName, proxiedResponse); } }; + reqProxy.proxyRequest(); response.failedNodes = reqProxy.getFailedNodes(); } catch (SolrException e) { diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java index 4ec5b7ee38d0..7f1166a817f0 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java @@ -78,7 +78,7 @@ public void testReliesOnLogWatcherToListLogLevels() { .thenReturn(List.of("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); when(mockLogWatcher.getAllLoggers()) .thenReturn(List.of(logInfo("org.a.s.Foo", "WARN", true), logInfo("org", null, false))); - final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(); + final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(null); assertEquals(5, responseBody.levels.size()); assertThat(responseBody.levels, containsInAnyOrder("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); @@ -94,6 +94,38 @@ public void testReliesOnLogWatcherToListLogLevels() { assertTrue("Expected logger info to report 'set'", secondLogger.set); } + @Test + public void testListLogLevelsWithoutNodesReportsNoBroadcastResults() { + when(mockLogWatcher.getAllLevels()) + .thenReturn(List.of("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); + when(mockLogWatcher.getAllLoggers()).thenReturn(List.of()); + + final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(null); + + assertTrue( + "Expected no per-node results for a local request, but was " + responseBody.remoteNodeData, + responseBody.remoteNodeData.isEmpty()); + assertNull( + "Expected failedNodes to be unset for a local request, but was " + responseBody.failedNodes, + responseBody.failedNodes); + } + + @Test + public void testListLogLevelsWithNodesFailsOutsideSolrCloud() { + // The mock CoreContainer has no ZkController stubbed, as on a standalone (user-managed) + // node, so NodeLogging sees a null ZkController here. + try { + new NodeLogging(mockCoreContainer).listAllLoggersAndLevels("all"); + fail("Expected a SolrException when 'nodes' is used outside SolrCloud mode"); + } catch (SolrException e) { + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code()); + assertTrue( + "Expected the error to name the 'nodes' parameter, but was: " + e.getMessage(), + e.getMessage().contains("'nodes'")); + } + verify(mockLogWatcher, never()).getAllLoggers(); + } + @Test public void testReliesOnLogWatcherToModifyLogLevels() { final var responseBody = diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java index 37ddcca4fb20..34edaffc6cf1 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java @@ -30,8 +30,8 @@ import org.junit.Test; /** - * Tests the {@code nodes} broadcast on the V2 {@code PUT /node/logging/levels} endpoint against a - * two-node cluster. + * Tests the {@code nodes} broadcast on the V2 logging endpoints ({@code PUT /node/logging/levels} + * and {@code GET /node/logging/levels}) against a two-node cluster. * *

Requests go through the generated SolrJ client, so this also exercises its codegen. Log levels * themselves cannot be observed per node here: every node of the test cluster shares one JVM, and @@ -122,6 +122,80 @@ public void testUnknownNodeNameFailsFast() throws Exception { } } + @Test + public void testBroadcastLevelsToAllNodesReportsEveryNode() throws Exception { + final String receivingNode = jettyName(0); + final String otherNode = jettyName(1); + + final JsonNode rsp = getLogLevels(cluster.getJettySolrRunners().get(0), "all"); + + final JsonNode failedNodes = rsp.get("failedNodes"); + assertNotNull("Broadcast response should report failed nodes", failedNodes); + assertTrue("No node should be reported failed: " + failedNodes, failedNodes.isEmpty()); + // The receiving node does not also serve the listing locally, so the top level carries no + // levels or loggers of its own; the listings live under the per-node entries. + assertNull("Expected no top-level 'levels' in a broadcast response: " + rsp, rsp.get("levels")); + assertNull( + "Expected no top-level 'loggers' in a broadcast response: " + rsp, rsp.get("loggers")); + for (String nodeName : new String[] {receivingNode, otherNode}) { + final JsonNode perNode = rsp.get(nodeName); + assertNotNull("Expected a per-node result for " + nodeName + " in " + rsp, perNode); + // The per-node entry is that node's own levels listing. + assertNotNull(perNode.get("watcher")); + assertNotNull(perNode.get("levels")); + final JsonNode loggers = perNode.get("loggers"); + assertNotNull(loggers); + assertTrue("Expected a non-empty logger list for " + nodeName, loggers.size() > 0); + } + } + + @Test + public void testBroadcastLevelsToSingleNamedNode() throws Exception { + final String receivingNode = jettyName(0); + final String otherNode = jettyName(1); + + final JsonNode rsp = getLogLevels(cluster.getJettySolrRunners().get(0), otherNode); + + assertNotNull(rsp.get("failedNodes")); + assertTrue(rsp.get("failedNodes").isEmpty()); + final JsonNode perNode = rsp.get(otherNode); + assertNotNull("Expected a per-node result for " + otherNode, perNode); + assertNotNull(perNode.get("loggers")); + assertNull("The receiving node was not a target and must not appear", rsp.get(receivingNode)); + } + + @Test + public void testNoNodesLevelsKeepsLocalResponseShape() throws Exception { + final JsonNode rsp = getLogLevels(cluster.getJettySolrRunners().get(0), null); + + assertNotNull(rsp.get("watcher")); + assertNotNull(rsp.get("levels")); + final JsonNode loggers = rsp.get("loggers"); + assertNotNull(loggers); + assertTrue("Expected a non-empty logger list", loggers.size() > 0); + assertNull(rsp.get("failedNodes")); + rsp.fieldNames() + .forEachRemaining( + field -> + assertFalse("No per-node entries expected: " + field, field.endsWith("_solr"))); + } + + @Test + public void testUnknownNodeNameFailsFastForLevelsGet() { + final GenericV2SolrRequest req = + new GenericV2SolrRequest( + SolrRequest.METHOD.GET, + "/node/logging/levels", + SolrRequest.SolrRequestType.ADMIN, + new ModifiableSolrParams().set("nodes", "no-such-host.invalid:9999_solr")); + final RemoteSolrException e = + expectThrows(RemoteSolrException.class, () -> sendWithDefaultParser(jetty(0), req)); + assertEquals(400, e.code()); + assertTrue( + "Error should name the problem: " + e.getMessage(), + e.getMessage().contains("not part of the cluster")); + } + private static String jettyName(int idx) { return cluster.getJettySolrRunners().get(idx).getNodeName(); } @@ -144,6 +218,34 @@ private static LoggingResponse putLogLevel( } } + private static JsonNode getLogLevels(JettySolrRunner target, String nodes) throws Exception { + final ModifiableSolrParams params = new ModifiableSolrParams(); + if (nodes != null) { + params.set("nodes", nodes); + } + final GenericV2SolrRequest req = + new GenericV2SolrRequest( + SolrRequest.METHOD.GET, + "/node/logging/levels", + SolrRequest.SolrRequestType.ADMIN, + params); + req.setResponseParser(new InputStreamResponseParser("json")); + try (HttpJettySolrClient client = + new HttpJettySolrClient.Builder(target.getBaseUrl().toString()).build()) { + final String body = + InputStreamResponseParser.consumeResponseToString(req.process(client).getResponse()); + return MAPPER.readTree(body); + } + } + + private static SimpleSolrResponse sendWithDefaultParser( + JettySolrRunner target, GenericV2SolrRequest req) throws Exception { + try (HttpJettySolrClient client = + new HttpJettySolrClient.Builder(target.getBaseUrl().toString()).build()) { + return req.process(client); + } + } + private static ListLevelsResponse getLogLevels(int targetIdx) throws Exception { final var req = new LoggingApi.ListAllLoggersAndLevels(); try (HttpJettySolrClient client = client(targetIdx)) { From 060dc3d47db81dc072ead8e0b50776d0d653be97 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 5 Oct 2026 22:19:38 +0000 Subject: [PATCH 3/6] SOLR-15823: Document the nodes parameter on the V2 logging levels listing --- .../deployment-guide/pages/configuring-logging.adoc | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc index 2db5276470bf..fd3d3f0fb4d8 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc @@ -125,6 +125,14 @@ curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all" -H 'Conten ' ---- +The `nodes` parameter works the same way on the V2 log level listing, `GET /api/node/logging/levels`. +The response then carries each node's listing under its node name instead of a single top-level listing, and names any requested nodes that did not respond under `failedNodes`. + +[source,bash] +---- +curl "http://localhost:8983/api/node/logging/levels?nodes=all" +---- + === Choosing Log Level at Startup You can temporarily choose a different logging level as you start Solr. From 0f76bbba5ad2f51bf8853717327d281c3f610a6c Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Mon, 5 Oct 2026 22:56:34 +0000 Subject: [PATCH 4/6] SOLR-15823: Pass an options object to the Admin UI levels listing call Adding the optional 'nodes' parameter to the levels GET changed the generated JS client signature from listAllLoggersAndLevels(callback) to listAllLoggersAndLevels(opts, callback). The logging screen still passed its callback first, so it landed in 'opts' and the response handler never ran. Pass an empty options object, as the neighbouring fetchLocalLogMessages call already does. --- solr/webapp/web/js/angular/controllers/logging.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/webapp/web/js/angular/controllers/logging.js b/solr/webapp/web/js/angular/controllers/logging.js index c2f6d46eb6db..1129dae1603c 100644 --- a/solr/webapp/web/js/angular/controllers/logging.js +++ b/solr/webapp/web/js/angular/controllers/logging.js @@ -126,7 +126,7 @@ solrAdminApp.controller('LoggingController', }; $scope.refresh = function() { - LoggingV2.listAllLoggersAndLevels(function(error, data, response) { + LoggingV2.listAllLoggersAndLevels({}, function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } $scope.logging = makeTree(data.loggers, ""); From 251460b7c14fa4764c04096a69c2c7d279f9d2d2 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Fri, 23 Oct 2026 01:07:41 +0000 Subject: [PATCH 5/6] SOLR-15823: restore test imports lost in the #5030 restack --- .../handler/admin/api/NodeLoggingNodesSolrCloudTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java index 34edaffc6cf1..4af7ee66c8c0 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java @@ -16,15 +16,22 @@ */ package org.apache.solr.handler.admin.api; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Map; import org.apache.solr.client.api.model.ListLevelsResponse; import org.apache.solr.client.api.model.LogLevelChange; import org.apache.solr.client.api.model.LogLevelInfo; import org.apache.solr.client.api.model.LoggingResponse; import org.apache.solr.client.solrj.RemoteSolrException; +import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.jetty.HttpJettySolrClient; +import org.apache.solr.client.solrj.request.GenericV2SolrRequest; import org.apache.solr.client.solrj.request.LoggingApi; +import org.apache.solr.client.solrj.response.InputStreamResponseParser; +import org.apache.solr.client.solrj.response.SimpleSolrResponse; import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.embedded.JettySolrRunner; import org.junit.BeforeClass; import org.junit.Test; @@ -46,6 +53,8 @@ public class NodeLoggingNodesSolrCloudTest extends SolrCloudTestCase { private static final String TEST_LOGGER = "org.apache.solr.handler.admin.api.NodeLoggingNodesSolrCloudTest"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + @BeforeClass public static void setupCluster() throws Exception { configureCluster(2).addConfig("cloud-minimal", configset("cloud-minimal")).configure(); From c4d0dc38926fd9a1ab1a02c45775bec70cad6dc0 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Wed, 28 Oct 2026 01:13:14 -0400 Subject: [PATCH 6/6] SOLR-15823: Remove duplicate logging broadcast example --- .../deployment-guide/pages/configuring-logging.adoc | 9 --------- 1 file changed, 9 deletions(-) diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc index fd3d3f0fb4d8..27bbdd34a5b3 100644 --- a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc +++ b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc @@ -116,15 +116,6 @@ curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all" -H 'Conten ' ---- -[source,bash] ----- -curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all" -H 'Content-Type: application/json' -d ' - [ - {"logger": "root", "level": "WARN"} - ] -' ----- - The `nodes` parameter works the same way on the V2 log level listing, `GET /api/node/logging/levels`. The response then carries each node's listing under its node name instead of a single top-level listing, and names any requested nodes that did not respond under `failedNodes`.