Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<LogLevelInfo> loggerInfo =
Expand All @@ -96,7 +104,10 @@ public LoggingResponse modifyLocalLogLevel(String nodes, List<LogLevelChange> 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;
}

Expand All @@ -116,24 +127,20 @@ public LoggingResponse modifyLocalLogLevel(String nodes, List<LogLevelChange> 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<LogLevelChange> requestBody) {
private <T extends LoggingResponse> void proxyToNodes(T response, SolrRequest<T> 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<LoggingResponse>(coreContainer, req) {
new V2SolrRequestBasedProxy<T>(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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion solr/webapp/web/js/angular/controllers/logging.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down
Loading