You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ge...@apache.org on 2023/04/14 15:35:34 UTC

[solr] branch branch_9x updated: SOLR-16458: Migrate v2 logging APIs to JAX-RS (#1160)

This is an automated email from the ASF dual-hosted git repository.

gerlowskija pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new 245f7938e26 SOLR-16458: Migrate v2 logging APIs to JAX-RS (#1160)
245f7938e26 is described below

commit 245f7938e26e3eb02a31918d3c87b4df16a2d342
Author: Calvince Otieno <85...@users.noreply.github.com>
AuthorDate: Tue Apr 11 19:53:41 2023 +0300

    SOLR-16458: Migrate v2 logging APIs to JAX-RS (#1160)
    
    This commit splits up the various functional APIs in LoggingHandler and
    gives them separate JAX-RS endpoints.
    
    v2 APIs are now available for:
      - list logger levels (GET /api/node/logging/levels)
      - bulk update logger levels (PUT /api/node/logging/levels)
      - fetch log messages (GET /api/node/logging/messages)
      - tweak log-listener level (PUT /api/node/logging/messages/threshold)
    
    Co-authored-by: Calvince Otieno <si...@yahoo.com>
    Co-authored-by: Jason Gerlowski <ge...@apache.org>
---
 solr/CHANGES.txt                                   |   6 +
 .../org/apache/solr/handler/admin/InfoHandler.java |  12 +
 .../apache/solr/handler/admin/LoggingHandler.java  |  86 ++-----
 .../solr/handler/admin/api/NodeLoggingAPI.java     | 260 +++++++++++++++++++--
 .../{NodeLoggingAPI.java => V2NodeLoggingAPI.java} |  41 ++--
 .../solr/handler/admin/LoggingHandlerTest.java     |  22 +-
 .../solr/handler/admin/api/NodeLoggingAPITest.java | 202 ++++++++++++++++
 .../handler/admin/api/V2NodeAPIMappingTest.java    |  22 --
 .../pages/configuring-logging.adoc                 |  25 +-
 9 files changed, 543 insertions(+), 133 deletions(-)

diff --git a/solr/CHANGES.txt b/solr/CHANGES.txt
index 909d3146bff..fd5fd430ec2 100644
--- a/solr/CHANGES.txt
+++ b/solr/CHANGES.txt
@@ -42,6 +42,12 @@ Improvements
 * SOLR-16394: The path of the v2 "collection backup" API has been tweaked slightly to be more intuitive, and is now available at
   `POST /api/collections/backups/backupName/versions`. (Jason Gerlowski)
 
+* SOLR-16458: Solr's v2 "logging" APIs have been tweaked slightly to be more intuitive.  The single v2 endpoint that existing previously
+  (GET /api/node/logging) has been split into several different APIs.  Logger levels can be fetched or modified at
+  `GET /api/node/logging/levels` and `PUT /api/node/logging/levels`, respectively.  Log messages can be returned from a "listener" at
+  `GET /api/node/logging/messages`.  And the threshold for the log "listener" can be modified with
+  `PUT /api/node/logging/messages/threshold`.  (Jason Gerlowski, Calvince Otieno)
+
 Optimizations
 ---------------------
 
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/InfoHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/InfoHandler.java
index 1b44c63dc6e..b982b468935 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/InfoHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/InfoHandler.java
@@ -25,6 +25,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import org.apache.solr.api.Api;
+import org.apache.solr.api.JerseyResource;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.util.NamedList;
 import org.apache.solr.core.CoreContainer;
@@ -169,6 +170,17 @@ public class InfoHandler extends RequestHandlerBase {
     return List.copyOf(list);
   }
 
+  @Override
+  public Collection<Class<? extends JerseyResource>> getJerseyResources() {
+    final var apis = new ArrayList<Class<? extends JerseyResource>>();
+    apis.addAll(handlers.get("threads").getJerseyResources());
+    apis.addAll(handlers.get("properties").getJerseyResources());
+    apis.addAll(handlers.get("logging").getJerseyResources());
+    apis.addAll(handlers.get("system").getJerseyResources());
+    apis.addAll(handlers.get("health").getJerseyResources());
+    return apis;
+  }
+
   @Override
   public Name getPermissionName(AuthorizationContext request) {
     // Delegate permission to the actual handler
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 9d280ecde02..67dbe69cd9c 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
@@ -19,21 +19,17 @@ package org.apache.solr.handler.admin;
 import java.lang.invoke.MethodHandles;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.apache.solr.api.AnnotatedApi;
 import org.apache.solr.api.Api;
-import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.api.JerseyResource;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrException.ErrorCode;
 import org.apache.solr.common.params.SolrParams;
-import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.core.CoreContainer;
 import org.apache.solr.handler.RequestHandlerBase;
 import org.apache.solr.handler.admin.api.NodeLoggingAPI;
+import org.apache.solr.handler.api.V2ApiUtils;
 import org.apache.solr.logging.LogWatcher;
-import org.apache.solr.logging.LoggerInfo;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.response.SolrQueryResponse;
 import org.apache.solr.security.AuthorizationContext;
@@ -58,42 +54,26 @@ public class LoggingHandler extends RequestHandlerBase {
 
   @Override
   public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
-    // Don't do anything if the framework is unknown
-    if (watcher == null) {
-      rsp.add("error", "Logging Not Initialized");
-      return;
-    }
-    rsp.add("watcher", watcher.getName());
+    final NodeLoggingAPI loggingApi = new NodeLoggingAPI(cc);
 
     SolrParams params = req.getParams();
     if (params.get("threshold") != null) {
-      watcher.setThreshold(params.get("threshold"));
+      squashV2Response(
+          rsp,
+          loggingApi.setMessageThreshold(
+              new NodeLoggingAPI.SetThresholdRequestBody(params.get("threshold"))));
     }
 
     // Write something at each level
     if (params.get("test") != null) {
-      log.trace("trace message");
-      log.debug("debug message");
-      RuntimeException exc = new RuntimeException("test");
-      log.info("info (with exception) INFO", exc);
-      log.warn("warn (with exception) WARN", exc);
-      log.error("error (with exception) ERROR", exc);
+      NodeLoggingAPI.writeLogsForTesting();
     }
 
     String[] set = params.getParams("set");
     if (set != null) {
-      for (String pair : set) {
-        String[] split = pair.split(":");
-        if (split.length != 2) {
-          throw new SolrException(
-              SolrException.ErrorCode.SERVER_ERROR,
-              "Invalid format, expected level:value, got " + pair);
-        }
-        String category = split[0];
-        String level = split[1];
-
-        watcher.setLogLevel(category, level);
-      }
+      final List<NodeLoggingAPI.LogLevelChange> changes =
+          NodeLoggingAPI.LogLevelChange.createRequestBodyFromV1Params(set);
+      squashV2Response(rsp, loggingApi.modifyLocalLogLevel(changes));
     }
 
     String since = req.getParams().get("since");
@@ -104,44 +84,21 @@ public class LoggingHandler extends RequestHandlerBase {
       } catch (Exception ex) {
         throw new SolrException(ErrorCode.BAD_REQUEST, "invalid timestamp: " + since);
       }
-      AtomicBoolean found = new AtomicBoolean(false);
-      SolrDocumentList docs = watcher.getHistory(time, found);
-      if (docs == null) {
-        rsp.add("error", "History not enabled");
-        return;
-      } else {
-        SimpleOrderedMap<Object> info = new SimpleOrderedMap<>();
-        if (time > 0) {
-          info.add("since", time);
-          info.add("found", found.get());
-        } else {
-          info.add("levels", watcher.getAllLevels()); // show for the first request
-        }
-        info.add("last", watcher.getLastEvent());
-        info.add("buffer", watcher.getHistorySize());
-        info.add("threshold", watcher.getThreshold());
-
-        rsp.add("info", info);
-        rsp.add("history", docs);
-      }
+      squashV2Response(rsp, loggingApi.fetchLocalLogMessages(time));
     } else {
-      rsp.add("levels", watcher.getAllLevels());
-
-      List<LoggerInfo> loggers = new ArrayList<>(watcher.getAllLoggers());
-      Collections.sort(loggers);
-
-      List<SimpleOrderedMap<?>> info = new ArrayList<>();
-      for (LoggerInfo wrap : loggers) {
-        info.add(wrap.getInfo());
-      }
-      rsp.add("loggers", info);
+      squashV2Response(rsp, loggingApi.listAllLoggersAndLevels());
     }
+
     rsp.setHttpCaching(false);
     if (cc != null && AdminHandlersProxy.maybeProxyToNodes(req, rsp, cc)) {
       return; // Request was proxied to other node
     }
   }
 
+  private void squashV2Response(SolrQueryResponse rsp, NodeLoggingAPI.LoggingResponse response) {
+    V2ApiUtils.squashIntoSolrResponseWithoutHeader(rsp, response);
+  }
+
   // ////////////////////// SolrInfoMBeans methods //////////////////////
 
   @Override
@@ -156,7 +113,12 @@ public class LoggingHandler extends RequestHandlerBase {
 
   @Override
   public Collection<Api> getApis() {
-    return AnnotatedApi.getApis(new NodeLoggingAPI(this));
+    return new ArrayList<>();
+  }
+
+  @Override
+  public Collection<Class<? extends JerseyResource>> getJerseyResources() {
+    return List.of(NodeLoggingAPI.class);
   }
 
   @Override
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLoggingAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLoggingAPI.java
index a65da6d1ac7..742a53d2d95 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLoggingAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLoggingAPI.java
@@ -17,33 +17,257 @@
 
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET;
+import static org.apache.solr.client.solrj.impl.BinaryResponseParser.BINARY_CONTENT_TYPE_V2;
+import static org.apache.solr.common.SolrException.ErrorCode.BAD_REQUEST;
 import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_EDIT_PERM;
+import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM;
 
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.handler.admin.LoggingHandler;
-import org.apache.solr.request.SolrQueryRequest;
-import org.apache.solr.response.SolrQueryResponse;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.lang.invoke.MethodHandles;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import javax.ws.rs.GET;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import org.apache.solr.api.JerseyResource;
+import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.jersey.JacksonReflectMapWriter;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SolrJerseyResponse;
+import org.apache.solr.logging.LogWatcher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+// TODO Add support for 'nodes' param once SOLR-16738 is completed.
 /**
- * V2 API for getting or setting log levels on an individual node.
+ * V2 APIs for getting or setting log levels on an individual node.
  *
- * <p>This API (GET /v2/node/logging) is analogous to the v1 /admin/info/logging.
+ * <p>These APIs ('/api/node/logging' and descendants) are analogous to the v1 /admin/info/logging.
  */
-public class NodeLoggingAPI {
+@Path("/node/logging")
+public class NodeLoggingAPI extends JerseyResource {
 
-  private final LoggingHandler handler;
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
-  public NodeLoggingAPI(LoggingHandler handler) {
-    this.handler = handler;
+  private final CoreContainer coreContainer;
+  private final LogWatcher<?> watcher;
+
+  @Inject
+  public NodeLoggingAPI(CoreContainer coreContainer) {
+    this.coreContainer = coreContainer;
+    this.watcher = coreContainer.getLogging();
+  }
+
+  @Path("/levels")
+  @GET
+  @PermissionName(CONFIG_READ_PERM)
+  @Produces({"application/json", "application/xml", BINARY_CONTENT_TYPE_V2})
+  public ListLevelsResponse listAllLoggersAndLevels() {
+    ensureLogWatcherEnabled();
+    final ListLevelsResponse response = instantiateLoggingResponse(ListLevelsResponse.class);
+
+    response.levels = watcher.getAllLevels();
+
+    final List<LogLevelInfo> loggerInfo =
+        watcher.getAllLoggers().stream()
+            .sorted()
+            .map(li -> new LogLevelInfo(li.getName(), li.getLevel(), li.isSet()))
+            .collect(Collectors.toList());
+
+    response.loggers = loggerInfo;
+
+    return response;
+  }
+
+  @Path("/levels")
+  @PUT
+  @PermissionName(CONFIG_EDIT_PERM)
+  @Produces({"application/json", "application/xml", BINARY_CONTENT_TYPE_V2})
+  public LoggingResponse modifyLocalLogLevel(List<LogLevelChange> requestBody) {
+    ensureLogWatcherEnabled();
+    final LoggingResponse response = instantiateLoggingResponse(LoggingResponse.class);
+
+    if (requestBody == null) {
+      throw new SolrException(BAD_REQUEST, "Missing request body");
+    }
+
+    for (LogLevelChange change : requestBody) {
+      watcher.setLogLevel(change.logger, change.level);
+    }
+    return response;
+  }
+
+  @Path("/messages")
+  @GET
+  @PermissionName(CONFIG_READ_PERM)
+  @Produces({"application/json", "application/xml", BINARY_CONTENT_TYPE_V2})
+  public LogMessagesResponse fetchLocalLogMessages(@QueryParam("since") Long boundingTimeMillis) {
+    ensureLogWatcherEnabled();
+    final LogMessagesResponse response = instantiateLoggingResponse(LogMessagesResponse.class);
+    if (boundingTimeMillis == null) {
+      throw new SolrException(BAD_REQUEST, "Missing required parameter, 'since'.");
+    }
+
+    AtomicBoolean found = new AtomicBoolean(false);
+    SolrDocumentList docs = watcher.getHistory(boundingTimeMillis, found);
+    if (docs == null) {
+      throw new SolrException(BAD_REQUEST, "History not enabled");
+    }
+
+    final LogMessageInfo info = new LogMessageInfo();
+    if (boundingTimeMillis > 0) {
+      info.boundingTimeMillis = boundingTimeMillis;
+      info.found = found.get();
+    } else {
+      info.levels = watcher.getAllLevels(); // show for the first request
+    }
+    info.lastRecordTimestampMillis = watcher.getLastEvent();
+    info.buffer = watcher.getHistorySize();
+
+    response.info = info;
+    response.docs = docs;
+
+    return response;
+  }
+
+  @Path("/messages/threshold")
+  @PUT
+  @PermissionName(CONFIG_EDIT_PERM)
+  @Produces({"application/json", "application/xml", BINARY_CONTENT_TYPE_V2})
+  public LoggingResponse setMessageThreshold(SetThresholdRequestBody requestBody) {
+    ensureLogWatcherEnabled();
+    final LoggingResponse response = instantiateLoggingResponse(LoggingResponse.class);
+
+    if (requestBody == null || requestBody.level == null) {
+      throw new SolrException(BAD_REQUEST, "Required parameter 'level' missing");
+    }
+    watcher.setThreshold(requestBody.level);
+
+    return response;
+  }
+
+  // A hacky testing-only parameter used to test the v1 LoggingHandler
+  public static void writeLogsForTesting() {
+    log.trace("trace message");
+    log.debug("debug message");
+    RuntimeException exc = new RuntimeException("test");
+    log.info("info (with exception) INFO", exc);
+    log.warn("warn (with exception) WARN", exc);
+    log.error("error (with exception) ERROR", exc);
+  }
+
+  private void ensureLogWatcherEnabled() {
+    if (watcher == null) {
+      throw new SolrException(BAD_REQUEST, "Logging Not Initialized");
+    }
+  }
+
+  private <T extends LoggingResponse> T instantiateLoggingResponse(Class<T> clazz) {
+    final T response = instantiateJerseyResponse(clazz);
+    response.watcherName = watcher.getName();
+    return response;
   }
 
-  // TODO See SOLR-15823 for background on the (less than ideal) permission chosen here.
-  @EndPoint(
-      path = {"/node/logging"},
-      method = GET,
-      permission = CONFIG_EDIT_PERM)
-  public void getOrSetLogLevels(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
-    handler.handleRequestBody(req, rsp);
+  /** Generic logging response that includes the name of the log watcher (e.g. "Log4j2") */
+  public static class LoggingResponse extends SolrJerseyResponse {
+    @JsonProperty("watcher")
+    public String watcherName;
+  }
+
+  /** A user-requested modification in the level that a specified logger reports at. */
+  public static class LogLevelChange implements JacksonReflectMapWriter {
+    public LogLevelChange() {}
+
+    public LogLevelChange(String logger, String level) {
+      this.logger = logger;
+      this.level = level;
+    }
+
+    @JsonProperty public String logger;
+    @JsonProperty public String level;
+
+    public static List<LogLevelChange> createRequestBodyFromV1Params(String[] rawChangeValues) {
+      final List<LogLevelChange> changes = new ArrayList<>();
+
+      for (String rawChange : rawChangeValues) {
+        String[] split = rawChange.split(":");
+        if (split.length != 2) {
+          throw new SolrException(
+              SolrException.ErrorCode.SERVER_ERROR,
+              "Invalid format, expected level:value, got " + rawChange);
+        }
+        changes.add(new LogLevelChange(split[0], split[1]));
+      }
+
+      return changes;
+    }
+  }
+
+  /** The request body for the 'PUT /api/node/logging/messages/threshold' API. */
+  public static class SetThresholdRequestBody implements JacksonReflectMapWriter {
+    public SetThresholdRequestBody() {}
+
+    public SetThresholdRequestBody(String level) {
+      this.level = level;
+    }
+
+    @JsonProperty(required = true)
+    public String level;
+  }
+
+  /** Response format for the 'GET /api/node/logging/messages' API. */
+  public static class LogMessagesResponse extends LoggingResponse {
+    @JsonProperty public LogMessageInfo info;
+
+    @JsonProperty("history")
+    public SolrDocumentList docs;
+  }
+
+  /** Metadata about the log messages returned by the 'GET /api/node/logging/messages' API */
+  public static class LogMessageInfo implements JacksonReflectMapWriter {
+    @JsonProperty("since")
+    public Long boundingTimeMillis;
+
+    @JsonProperty public Boolean found;
+    @JsonProperty public List<String> levels;
+
+    @JsonProperty("last")
+    public long lastRecordTimestampMillis;
+
+    @JsonProperty public int buffer;
+    @JsonProperty public String threshold;
+  }
+
+  /** Response format for the 'GET /api/node/logging/levels' API. */
+  public static class ListLevelsResponse extends LoggingResponse {
+    @JsonProperty public List<String> levels;
+    @JsonProperty public List<LogLevelInfo> loggers;
+  }
+
+  /** Representation of a single logger and its current state. */
+  public static class LogLevelInfo implements JacksonReflectMapWriter {
+    public LogLevelInfo() {}
+
+    public LogLevelInfo(String name, String level, boolean set) {
+      this.name = name;
+      this.level = level;
+      this.set = set;
+    }
+
+    @JsonProperty("name")
+    public String name;
+
+    @JsonProperty("level")
+    public String level;
+
+    @JsonProperty("set")
+    public boolean set;
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLoggingAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/V2NodeLoggingAPI.java
similarity index 52%
copy from solr/core/src/java/org/apache/solr/handler/admin/api/NodeLoggingAPI.java
copy to solr/core/src/java/org/apache/solr/handler/admin/api/V2NodeLoggingAPI.java
index a65da6d1ac7..e89ad6d5a31 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLoggingAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/V2NodeLoggingAPI.java
@@ -17,33 +17,36 @@
 
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET;
 import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_EDIT_PERM;
 
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.handler.admin.LoggingHandler;
-import org.apache.solr.request.SolrQueryRequest;
-import org.apache.solr.response.SolrQueryResponse;
+import java.util.List;
+import javax.inject.Inject;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.logging.LogWatcher;
 
 /**
- * V2 API for getting or setting log levels on an individual node.
+ * V2 API for getting log levels on an individual node.
  *
- * <p>This API (GET /v2/node/logging) is analogous to the v1 /admin/info/logging.
+ * <p>The endpoint is defined as api/node/logging/levels
  */
-public class NodeLoggingAPI {
+@Path("/api/node/logging")
+public class V2NodeLoggingAPI {
+  private final LogWatcher<?> watcher;
 
-  private final LoggingHandler handler;
-
-  public NodeLoggingAPI(LoggingHandler handler) {
-    this.handler = handler;
+  @Inject
+  public V2NodeLoggingAPI(LogWatcher<?> watcher) {
+    this.watcher = watcher;
   }
 
-  // TODO See SOLR-15823 for background on the (less than ideal) permission chosen here.
-  @EndPoint(
-      path = {"/node/logging"},
-      method = GET,
-      permission = CONFIG_EDIT_PERM)
-  public void getOrSetLogLevels(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
-    handler.handleRequestBody(req, rsp);
+  @GET
+  @Path("/levels")
+  @PermissionName(CONFIG_EDIT_PERM)
+  @Produces(MediaType.APPLICATION_JSON)
+  public List<String> getAllLevels() {
+    return this.watcher.getAllLevels();
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/LoggingHandlerTest.java b/solr/core/src/test/org/apache/solr/handler/admin/LoggingHandlerTest.java
index 2e2ded7d248..097306e2088 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/LoggingHandlerTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/LoggingHandlerTest.java
@@ -16,7 +16,9 @@
  */
 package org.apache.solr.handler.admin;
 
-import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.ObjectUtils;
 import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.core.LoggerContext;
@@ -79,7 +81,7 @@ public class LoggingHandlerTest extends SolrTestCaseJ4 {
             new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/info/logging", mparams));
 
     @SuppressWarnings({"unchecked"})
-    ArrayList<NamedList<Object>> loggers = (ArrayList<NamedList<Object>>) rsp._get("loggers", null);
+    List<Map<String, Object>> loggers = (List<Map<String, Object>>) rsp._get("loggers", null);
 
     // check log levels
     assertTrue(checkLoggerLevel(loggers, PARENT_LOGGER_NAME, ""));
@@ -92,8 +94,8 @@ public class LoggingHandlerTest extends SolrTestCaseJ4 {
             new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/info/logging", mparams));
 
     @SuppressWarnings({"unchecked"})
-    ArrayList<NamedList<Object>> updatedLoggerLevel =
-        (ArrayList<NamedList<Object>>) rsp._get("loggers", null);
+    List<Map<String, Object>> updatedLoggerLevel =
+        (List<Map<String, Object>>) rsp._get("loggers", null);
 
     // check new parent logger level
     assertTrue(checkLoggerLevel(updatedLoggerLevel, PARENT_LOGGER_NAME, "TRACE"));
@@ -109,8 +111,8 @@ public class LoggingHandlerTest extends SolrTestCaseJ4 {
             new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/info/logging", mparams));
 
     @SuppressWarnings({"unchecked"})
-    ArrayList<NamedList<Object>> removedLoggerLevel =
-        (ArrayList<NamedList<Object>>) rsp._get("loggers", null);
+    List<Map<String, Object>> removedLoggerLevel =
+        (List<Map<String, Object>>) rsp._get("loggers", null);
 
     assertTrue(checkLoggerLevel(removedLoggerLevel, PARENT_LOGGER_NAME, "OFF"));
 
@@ -119,10 +121,10 @@ public class LoggingHandlerTest extends SolrTestCaseJ4 {
   }
 
   private boolean checkLoggerLevel(
-      ArrayList<NamedList<Object>> properties, String logger, String level) {
-    for (NamedList<Object> property : properties) {
-      String loggerProperty = property._get("name", "").toString();
-      String levelProperty = property._get("level", "").toString();
+      List<Map<String, Object>> properties, String logger, String level) {
+    for (Map<String, Object> property : properties) {
+      String loggerProperty = ObjectUtils.firstNonNull(property.get("name"), "").toString();
+      String levelProperty = ObjectUtils.firstNonNull(property.get("level"), "").toString();
       if (loggerProperty.equals(logger) && levelProperty.equals(level)) {
         return true;
       }
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
new file mode 100644
index 00000000000..667e30f3555
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java
@@ -0,0 +1,202 @@
+/*
+ * 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.solr.handler.admin.api;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import javax.inject.Singleton;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.Response;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.core.CoreContainer;
+import org.apache.solr.jersey.InjectionFactories;
+import org.apache.solr.jersey.SolrJacksonMapper;
+import org.apache.solr.logging.LogWatcher;
+import org.apache.solr.logging.LoggerInfo;
+import org.glassfish.hk2.utilities.binding.AbstractBinder;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/** Unit tests for the functionality offered in {@link NodeLoggingAPI} */
+@SuppressWarnings({"unchecked", "rawtypes"})
+public class NodeLoggingAPITest extends JerseyTest {
+
+  private CoreContainer mockCoreContainer;
+  private LogWatcher mockLogWatcher;
+
+  @BeforeClass
+  public static void ensureWorkingMockito() {
+    assumeWorkingMockito();
+  }
+
+  @Override
+  protected Application configure() {
+    setUpMocks();
+    final ResourceConfig config = new ResourceConfig();
+    config.register(NodeLoggingAPI.class);
+    config.register(SolrJacksonMapper.class);
+    config.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            bindFactory(new InjectionFactories.SingletonFactory<>(mockCoreContainer))
+                .to(CoreContainer.class)
+                .in(Singleton.class);
+          }
+        });
+
+    return config;
+  }
+
+  private void setUpMocks() {
+    mockCoreContainer = mock(CoreContainer.class);
+    mockLogWatcher = mock(LogWatcher.class);
+    when(mockCoreContainer.getLogging()).thenReturn(mockLogWatcher);
+  }
+
+  @Test
+  public void testReliesOnLogWatcherToListLogLevels() {
+    when(mockLogWatcher.getAllLevels())
+        .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 Response response = target("/node/logging/levels").request().get();
+    final var responseBody = response.readEntity(NodeLoggingAPI.ListLevelsResponse.class);
+
+    assertEquals(5, responseBody.levels.size());
+    assertThat(responseBody.levels, containsInAnyOrder("ERROR", "WARN", "INFO", "DEBUG", "TRACE"));
+
+    assertEquals(2, responseBody.loggers.size());
+    final var firstLogger = responseBody.loggers.get(0);
+    assertEquals("org", firstLogger.name);
+    assertEquals(null, firstLogger.level);
+    assertFalse("Expected logger info to report 'unset'", firstLogger.set);
+    final var secondLogger = responseBody.loggers.get(1);
+    assertEquals("org.a.s.Foo", secondLogger.name);
+    assertEquals("WARN", secondLogger.level);
+    assertTrue("Expected logger info to report 'set'", secondLogger.set);
+  }
+
+  @Test
+  public void testReliesOnLogWatcherToModifyLogLevels() {
+    final Response response =
+        target("/node/logging/levels")
+            .request()
+            .put(Entity.json("[{\"logger\": \"o.a.s.Foo\", \"level\": \"WARN\"}]"));
+    final var responseBody = response.readEntity(NodeLoggingAPI.LoggingResponse.class);
+
+    assertNotNull(responseBody);
+    assertNull("Expected error to be null but was " + responseBody.error, responseBody.error);
+    verify(mockLogWatcher).setLogLevel("o.a.s.Foo", "WARN");
+  }
+
+  private SolrDocumentList logMessageDocList(String... logMessages) {
+    final var docList = new SolrDocumentList();
+    for (String logMessage : logMessages) {
+      final var doc = new SolrDocument();
+      doc.addField("message_s", logMessage);
+      docList.add(doc);
+    }
+    return docList;
+  }
+
+  @Test
+  public void testReliesOnLogWatcherToFetchLogMessages() {
+    when(mockLogWatcher.getHistory(eq(123L), any()))
+        .thenReturn(logMessageDocList("logmsg1", "logmsg2"));
+    when(mockLogWatcher.getLastEvent()).thenReturn(123456L);
+    when(mockLogWatcher.getHistorySize()).thenReturn(321);
+
+    final var response = target("/node/logging/messages").queryParam("since", 123L).request().get();
+    final var responseBody = response.readEntity(NodeLoggingAPI.LogMessagesResponse.class);
+
+    assertNotNull(responseBody);
+    assertNull("Expected error to be null but was " + responseBody.error, responseBody.error);
+    assertNotNull("Expected 'info' subobject to be set, but was null", responseBody.info);
+    assertEquals(Long.valueOf(123), responseBody.info.boundingTimeMillis);
+    assertEquals(123456, responseBody.info.lastRecordTimestampMillis);
+    assertEquals(321, responseBody.info.buffer);
+    assertEquals(2, responseBody.docs.size());
+    assertEquals("logmsg1", responseBody.docs.get(0).getFieldValue("message_s"));
+    assertEquals("logmsg2", responseBody.docs.get(1).getFieldValue("message_s"));
+  }
+
+  @Test
+  public void testReliesOnLogWatcherToSetMessageThreshold() {
+    final var response =
+        target("/node/logging/messages/threshold")
+            .request()
+            .put(Entity.json("{\"level\": \"WARN\"}"));
+    final var responseBody = response.readEntity(NodeLoggingAPI.LoggingResponse.class);
+
+    assertNotNull(responseBody);
+    assertNull("Expected error to be null but was " + responseBody.error, responseBody.error);
+    verify(mockLogWatcher).setThreshold("WARN");
+  }
+
+  // The v1 logging APIs accept log level changes in the form set=logger:level&set=logger2:level2...
+  // This test ensures that we can convert that format to that used by our v2 APIs.
+  @Test
+  public void testCorrectlyParsesV1LogLevelChanges() {
+    final var levelChanges =
+        NodeLoggingAPI.LogLevelChange.createRequestBodyFromV1Params(
+            new String[] {"o.a.s.Foo:WARN", "o.a.s.Bar:INFO"});
+
+    assertEquals(2, levelChanges.size());
+    assertEquals("o.a.s.Foo", levelChanges.get(0).logger);
+    assertEquals("WARN", levelChanges.get(0).level);
+    assertEquals("o.a.s.Bar", levelChanges.get(1).logger);
+    assertEquals("INFO", levelChanges.get(1).level);
+  }
+
+  private static class StubLoggerInfo extends LoggerInfo {
+    private final boolean set;
+
+    public StubLoggerInfo(String logger, String level, boolean set) {
+      super(logger);
+      this.level = level;
+      this.set = set;
+    }
+
+    @Override
+    public boolean isSet() {
+      return set;
+    }
+  }
+
+  private LoggerInfo logInfo(String logger, String level, boolean set) {
+    return new StubLoggerInfo(logger, level, set);
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java
index 6e1181a5709..9f6af14d534 100644
--- a/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java
@@ -151,22 +151,6 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 {
     assertEquals("anyParamValue", v1Params.get("anyParamName"));
   }
 
-  @Test
-  public void testLogLevelsApiAllProperties() throws Exception {
-    final ModifiableSolrParams solrParams = new ModifiableSolrParams();
-    solrParams.add("since", "12345678");
-    solrParams.add("threshold", "someThresholdValue");
-    solrParams.add("test", "someTestValue");
-    solrParams.add("set", "SomeClassName");
-    final SolrParams v1Params = captureConvertedLoggingV1Params("/node/logging", "GET", solrParams);
-
-    // All parameters are passed through to v1 API as-is.
-    assertEquals("12345678", v1Params.get("since"));
-    assertEquals("someThresholdValue", v1Params.get("threshold"));
-    assertEquals("someTestValue", v1Params.get("test"));
-    assertEquals("SomeClassName", v1Params.get("set"));
-  }
-
   @Test
   public void testSystemInfoApiAllProperties() throws Exception {
     final ModifiableSolrParams solrParams = new ModifiableSolrParams();
@@ -201,11 +185,6 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 {
     return doCaptureParams(path, method, inputParams, null, mockSystemInfoHandler);
   }
 
-  private SolrParams captureConvertedLoggingV1Params(
-      String path, String method, SolrParams inputParams) throws Exception {
-    return doCaptureParams(path, method, inputParams, null, mockLoggingHandler);
-  }
-
   private SolrParams captureConvertedPropertiesV1Params(
       String path, String method, SolrParams inputParams) throws Exception {
     return doCaptureParams(path, method, inputParams, null, mockPropertiesHandler);
@@ -268,7 +247,6 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 {
     apiBag.registerObject(new RejoinLeaderElectionAPI(coreHandler));
     apiBag.registerObject(new NodePropertiesAPI(infoHandler.getPropertiesHandler()));
     apiBag.registerObject(new NodeThreadsAPI(infoHandler.getThreadDumpHandler()));
-    apiBag.registerObject(new NodeLoggingAPI(infoHandler.getLoggingHandler()));
     apiBag.registerObject(new NodeSystemInfoAPI(infoHandler.getSystemInfoHandler()));
     apiBag.registerObject(new NodeHealthAPI(infoHandler.getHealthCheckHandler()));
   }
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 d2b8a4cf39b..0dca9a4fa9d 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
@@ -76,11 +76,32 @@ There is also a way of sending REST commands to the `admin/info/logging` endpoin
 
 Example:
 
+[.dynamic-tabs]
+--
+[example.tab-pane#v1createcollection]
+====
+[.tab-label]*V1 API*
+
+[source,bash]
+----
+curl -X GET "http://localhost:8983/solr/admin/info/logging?set=root:WARN"
+----
+====
+
+[example.tab-pane#v2createcollection]
+====
+[.tab-label]*V2 API*
+
 [source,bash]
 ----
-# Set the root logger to level WARN
-curl -s http://localhost:8983/solr/admin/info/logging --data-binary "set=root:WARN"
+curl -X PUT http://localhost:8983/api/node/logging/levels -H 'Content-Type: application/json' -d '
+  [
+    {"logger": "root", "level": "WARN"}
+  ]
+'
 ----
+====
+--
 
 === Choosing Log Level at Startup