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..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,22 +16,29 @@ */ 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; /** - * 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 @@ -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(); @@ -122,6 +131,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 +227,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)) { 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..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,6 +116,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. 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, "");