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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-11984-c44f5b3388a8664a997b915f2d4b55c580ab3c90
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 9ec9c4fe8aa62c628c638ba5a625d2624b8a4849
Author: SEZ <[email protected]>
AuthorDate: Sat Aug 29 02:09:54 2026 +0000

    [Feature][Zeta] Read and change log levels on the v2 REST API (#11984)
---
 docs/en/engines/zeta/logging.md                    |  20 ++
 docs/en/engines/zeta/rest-api-v2.md                | 166 ++++++++++++
 .../introduction/concepts/incompatible-changes.md  |   6 +
 docs/zh/engines/zeta/logging.md                    |  18 ++
 docs/zh/engines/zeta/rest-api-v2.md                | 160 +++++++++++
 .../introduction/concepts/incompatible-changes.md  |   6 +
 .../org/apache/seatunnel/engine/e2e/RestApiIT.java | 102 +++++++
 .../seatunnel/engine/server/JettyService.java      |   4 +
 .../server/log/Log4j2HttpPostCommandProcessor.java |  25 +-
 .../seatunnel/engine/server/log/LogLevels.java     | 241 +++++++++++++++++
 .../seatunnel/engine/server/rest/RestConstant.java |   2 +
 .../server/rest/service/LoggerLevelService.java    | 301 +++++++++++++++++++++
 .../engine/server/rest/servlet/LoggersServlet.java | 186 +++++++++++++
 .../seatunnel/engine/server/log/LogLevelsTest.java | 209 ++++++++++++++
 14 files changed, 1439 insertions(+), 7 deletions(-)

diff --git a/docs/en/engines/zeta/logging.md b/docs/en/engines/zeta/logging.md
index 0fb82ae44b..3abb488629 100644
--- a/docs/en/engines/zeta/logging.md
+++ b/docs/en/engines/zeta/logging.md
@@ -95,6 +95,26 @@ For more details, please refer to the 
[REST-API](rest-api-v2.md).
 For a workflow to collect, redact, and analyze runtime logs, see
 [Diagnose Runtime Logs with AI Tools](log-analysis-with-ai.md).
 
+### Change Log Levels at Runtime
+
+There are two ways to change a log level, and they behave differently:
+
+- **Edit `log4j2.properties`**: Log4j 2 picks the change up on its next scan 
of the file (every 60
+  seconds by default, see `monitorInterval`), the change survives a restart, 
and it has to be rolled
+  out to every node.
+- **Call the `/loggers` REST API**: the change takes effect immediately on the 
node that serves the
+  request, or on every node with `?scope=cluster`, and it is lost when the 
node restarts.
+
+**Usage examples:**
+- List the loggers of a node and where their level comes from: 
`http://localhost:8080/loggers`
+- Raise one connector to `DEBUG` on the whole cluster:
+  `curl -X POST 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?level=DEBUG&scope=cluster'`
+- Drop the override again: `curl -X DELETE 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?scope=cluster'`
+
+A logger that was changed through the API reports `"origin": 
"runtime-override"`, so an override is
+never mistaken for the configured level. For the full request and response 
format, please refer to
+the [REST-API](rest-api-v2.md).
+
 ## SeaTunnel Log Configuration
 
 ### Scheduled deletion of old logs
diff --git a/docs/en/engines/zeta/rest-api-v2.md 
b/docs/en/engines/zeta/rest-api-v2.md
index 5bd9b821c2..4cbf697ca6 100644
--- a/docs/en/engines/zeta/rest-api-v2.md
+++ b/docs/en/engines/zeta/rest-api-v2.md
@@ -1378,6 +1378,172 @@ To get the content of a log file: 
`http://localhost:5801/log/job-898380162133917
 
 </details>
 
+------------------------------------------------------------------------------------------
+
+### Read And Change Log Levels
+
+A log level changed through these endpoints is a runtime override: it takes 
effect immediately, is
+node local, and is lost when the node restarts. Levels that should survive a 
restart belong in
+`config/log4j2.properties`, see [Logging](logging.md).
+
+The root logger is addressed as `root`.
+
+<details>
+ <summary><code>GET</code> <code><b>/loggers</b></code> <code>(Returns the 
loggers of the running configuration.)</code></summary>
+
+#### Query Parameters
+
+> |  Parameter Name  |   Required   |  Type   |                                
  Description                                   |
+> 
|------------------|--------------|---------|--------------------------------------------------------------------------------|
+> | scope            |   optional   | string  | `node` (default) answers for 
the node that serves the request, `cluster` asks every member |
+
+#### Response
+
+```json
+{
+  "node": "localhost:8080",
+  "loggers": [
+    {
+      "name": "root",
+      "level": "INFO",
+      "origin": "file"
+    },
+    {
+      "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+      "level": "DEBUG",
+      "origin": "runtime-override",
+      "fileLevel": "INFO"
+    }
+  ]
+}
+```
+
+`origin` tells where the current level comes from: `file` for the level of the 
log4j2 configuration
+file, `runtime-override` for a level that was set through one of the log level 
endpoints. `fileLevel`
+is only present when an overridden logger is configured in the file as well, 
and reports the level a
+`DELETE` puts back.
+
+With `?scope=cluster` the answer is one entry per member:
+
+```json
+{
+  "scope": "cluster",
+  "status": "SUCCESS",
+  "nodes": [
+    {
+      "node": "localhost:8080",
+      "loggers": [
+        {
+          "name": "root",
+          "level": "INFO",
+          "origin": "file"
+        }
+      ]
+    }
+  ]
+}
+```
+
+`status` is `SUCCESS` when every member answered, `PARTIAL_FAILURE` when some 
did not, and `FAILURE`
+when none did; the member that failed carries its own `status` and `error`. A 
cluster request reaches
+every member on the REST port of its configuration, so it does not reach 
members that took a
+different port through `enable-dynamic-port`.
+
+</details>
+
+<details>
+ <summary><code>GET</code> <code><b>/loggers/:name</b></code> <code>(Returns 
the effective level of one logger.)</code></summary>
+
+#### Response
+
+The level is resolved through the closest configured ancestor, so a logger 
that is not configured
+itself can be asked about as well.
+
+```json
+{
+  "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+  "level": "INFO",
+  "origin": "file",
+  "node": "localhost:8080"
+}
+```
+
+</details>
+
+<details>
+ <summary><code>POST</code> <code><b>/loggers/:name</b></code> 
<code>(Overrides the level of one logger.)</code></summary>
+
+#### Query Parameters
+
+> |  Parameter Name  |   Required   |  Type   |                                
  Description                                   |
+> 
|------------------|--------------|---------|--------------------------------------------------------------------------------|
+> | level            |   optional   | string  | `OFF`, `FATAL`, `ERROR`, 
`WARN`, `INFO`, `DEBUG`, `TRACE` or `ALL`, any letter case; may also be sent in 
the body |
+> | scope            |   optional   | string  | `node` (default) changes the 
node that serves the request, `cluster` changes every member |
+
+#### Body
+
+```json
+{
+  "level": "DEBUG"
+}
+```
+
+#### Response
+
+```json
+{
+  "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+  "level": "DEBUG",
+  "origin": "runtime-override",
+  "node": "localhost:8080",
+  "previousLevel": "INFO",
+  "status": "SUCCESS"
+}
+```
+
+An unknown level is rejected with `400` and the list of valid levels instead 
of being reported as
+applied. Every change is written to the node log as a single `INFO` line with 
the logger, the old and
+the new level, the scope and the address of the caller.
+
+#### Examples
+
+Raise the JDBC connector to `DEBUG` on one node:
+`curl -X POST 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?level=DEBUG'`
+
+Raise it on every member of the cluster:
+`curl -X POST 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?level=DEBUG&scope=cluster'`
+
+</details>
+
+<details>
+ <summary><code>DELETE</code> <code><b>/loggers/:name</b></code> 
<code>(Reverts a runtime override.)</code></summary>
+
+#### Query Parameters
+
+> |  Parameter Name  |   Required   |  Type   |                                
  Description                                   |
+> 
|------------------|--------------|---------|--------------------------------------------------------------------------------|
+> | scope            |   optional   | string  | `node` (default) reverts the 
node that serves the request, `cluster` reverts every member |
+
+#### Response
+
+The logger goes back to the level it had before its first override, which is 
the level of the
+configuration file, or the level inherited from its parent when the file does 
not configure it.
+
+```json
+{
+  "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+  "level": "INFO",
+  "origin": "file",
+  "node": "localhost:8080",
+  "previousLevel": "DEBUG",
+  "status": "SUCCESS"
+}
+```
+
+`status` is `NO_OVERRIDE` when the logger was never overridden through an 
endpoint; nothing is
+changed in that case.
+
+</details>
 
 ### Get Node Metrics
 
diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index 41437be0be..b64f17b2b6 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -60,6 +60,12 @@ You need to check this document before you upgrade to 
related version.
   }
   ```
 
+- **Breaking Change: An unknown log level is rejected by the runtime log level 
endpoint**
+  - **Affected component**: SeaTunnel Engine REST API — `POST 
/hazelcast/rest/maps/log-level`
+  - **Description**: The endpoint answered `200` with `{"status":"SUCCESS"}` 
for every request, including a level name it could not resolve (`DEBUGG`, 
`verbose`, a lowercase name of a level that does not exist, an empty value). 
Nothing was applied in that case, and the unresolved level was handed to log4j2 
as `null`, which removes the explicit level of the logger instead of leaving it 
alone — so the logger silently fell back to its parent, or to `ERROR` for the 
root logger. An unknown le [...]
+  - **Impact**: Scripts and automation that only check the HTTP status now see 
`400` where they used to see `200`, for requests that never took effect in the 
first place. Requests with a resolvable level are unchanged.
+  - **Migration Guide**: Send a level log4j2 knows (`OFF`, `FATAL`, `ERROR`, 
`WARN`, `INFO`, `DEBUG`, `TRACE`, `ALL`, or a level registered by the 
configuration). The response body of a rejected request names the levels the 
node accepts.
+
 - **Breaking Change: `Condition.of(option, null)` no longer allowed**
   - **Affected component**: `seatunnel-api` — 
`org.apache.seatunnel.api.configuration.util.Condition`
   - **Description**: The `Condition` constructor now validates that binary 
literal operators (such as `EQUAL`, `NOT_EQUAL`, `GREATER_THAN`, etc.) must 
have a non-null `expectValue`. Previously, `Condition.of(option, null)` was 
silently accepted; it now throws `IllegalArgumentException` at construction 
time.
diff --git a/docs/zh/engines/zeta/logging.md b/docs/zh/engines/zeta/logging.md
index d439920dd8..d1cfcdce22 100644
--- a/docs/zh/engines/zeta/logging.md
+++ b/docs/zh/engines/zeta/logging.md
@@ -94,6 +94,24 @@ SeaTunnel 提供了一个 API,用于查询日志。
 
 有关收集、脱敏和分析运行日志的流程,请参阅[使用 AI 辅助诊断运行日志](log-analysis-with-ai.md)。
 
+### 运行期修改日志级别
+
+修改日志级别有两种方式,二者行为不同:
+
+- **修改 `log4j2.properties`**:Log4j 2 在下一次扫描配置文件时生效(默认 60 秒一次,由
+  `monitorInterval` 控制),重启后依然有效,但需要在每个节点上分发。
+- **调用 `/loggers` REST 接口**:立即在接收请求的节点上生效,使用 `?scope=cluster` 时在所有节点上生效,
+  但节点重启后丢失。
+
+**使用样例:**
+- 查看某个节点的 logger 列表以及各自级别的来源:`http://localhost:8080/loggers`
+- 在整个集群上把某个连接器调整为 `DEBUG`:
+  `curl -X POST 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?level=DEBUG&scope=cluster'`
+- 撤销该覆盖:`curl -X DELETE 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?scope=cluster'`
+
+通过接口修改过的 logger 会返回 `"origin": "runtime-override"`,因此运行时覆盖不会被误认为是配置文件中的
+级别。完整的请求与响应格式请参阅 [REST-API](rest-api-v2.md)。
+
 ## SeaTunnel 日志配置
 
 ### 定时删除旧日志
diff --git a/docs/zh/engines/zeta/rest-api-v2.md 
b/docs/zh/engines/zeta/rest-api-v2.md
index 81ba2da7be..fb744e73ae 100644
--- a/docs/zh/engines/zeta/rest-api-v2.md
+++ b/docs/zh/engines/zeta/rest-api-v2.md
@@ -1362,6 +1362,166 @@ curl --location 
'http://127.0.0.1:8080/submit-job/upload?restoreMode=CHECKPOINT&;
 
 </details>
 
+------------------------------------------------------------------------------------------
+
+### 查看与修改日志级别
+
+通过这些接口修改的日志级别属于运行时覆盖:立即生效、仅作用于单个节点、节点重启后丢失。需要在重启后依然
+生效的级别请写入 `config/log4j2.properties`,参见 [日志](logging.md)。
+
+根 logger 的名字是 `root`。
+
+<details>
+ <summary><code>GET</code> <code><b>/loggers</b></code> <code>(返回当前生效配置中的 
logger 列表。)</code></summary>
+
+#### 请求参数
+
+> |     参数名      |   是否必填   |  类型   |                                   描述     
                               |
+> 
|----------------|--------------|---------|---------------------------------------------------------------------------|
+> | scope          |   optional   | string  | `node`(默认)只处理接收请求的节点,`cluster` 
会请求集群中的每个节点         |
+
+#### 响应
+
+```json
+{
+  "node": "localhost:8080",
+  "loggers": [
+    {
+      "name": "root",
+      "level": "INFO",
+      "origin": "file"
+    },
+    {
+      "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+      "level": "DEBUG",
+      "origin": "runtime-override",
+      "fileLevel": "INFO"
+    }
+  ]
+}
+```
+
+`origin` 表示当前级别的来源:`file` 表示来自 log4j2 配置文件,`runtime-override` 表示通过日志级别接口
+设置。只有当被覆盖的 logger 同时也在配置文件中配置过时才会返回 `fileLevel`,它就是 `DELETE` 会恢复的级别。
+
+使用 `?scope=cluster` 时每个节点返回一条记录:
+
+```json
+{
+  "scope": "cluster",
+  "status": "SUCCESS",
+  "nodes": [
+    {
+      "node": "localhost:8080",
+      "loggers": [
+        {
+          "name": "root",
+          "level": "INFO",
+          "origin": "file"
+        }
+      ]
+    }
+  ]
+}
+```
+
+所有节点都返回结果时 `status` 为 `SUCCESS`,部分节点失败时为 `PARTIAL_FAILURE`,全部失败时为
+`FAILURE`;失败的节点会带上自己的 `status` 与 `error`。集群请求按各节点配置中的 REST 端口访问,因此无法
+访问通过 `enable-dynamic-port` 使用了其它端口的节点。
+
+</details>
+
+<details>
+ <summary><code>GET</code> <code><b>/loggers/:name</b></code> <code>(返回单个 
logger 的生效级别。)</code></summary>
+
+#### 响应
+
+级别通过最近的已配置父级 logger 解析,因此也可以查询本身没有被配置的 logger。
+
+```json
+{
+  "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+  "level": "INFO",
+  "origin": "file",
+  "node": "localhost:8080"
+}
+```
+
+</details>
+
+<details>
+ <summary><code>POST</code> <code><b>/loggers/:name</b></code> <code>(修改单个 
logger 的级别。)</code></summary>
+
+#### 请求参数
+
+> |     参数名      |   是否必填   |  类型   |                                   描述     
                               |
+> 
|----------------|--------------|---------|---------------------------------------------------------------------------|
+> | level          |   optional   | string  | 
`OFF`、`FATAL`、`ERROR`、`WARN`、`INFO`、`DEBUG`、`TRACE` 或 `ALL`,不区分大小写;也可以放在请求体中 |
+> | scope          |   optional   | string  | `node`(默认)只修改接收请求的节点,`cluster` 
会修改集群中的每个节点        |
+
+#### 请求体
+
+```json
+{
+  "level": "DEBUG"
+}
+```
+
+#### 响应
+
+```json
+{
+  "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+  "level": "DEBUG",
+  "origin": "runtime-override",
+  "node": "localhost:8080",
+  "previousLevel": "INFO",
+  "status": "SUCCESS"
+}
+```
+
+未知级别不会被当作已生效处理,而是返回 `400` 并列出所有合法级别。每次修改都会在节点日志中输出一行 `INFO`
+记录,包含 logger 名、修改前后的级别、作用范围以及调用方地址。
+
+#### 例子
+
+把单个节点上的 JDBC 连接器调整为 `DEBUG`:
+`curl -X POST 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?level=DEBUG'`
+
+在集群的每个节点上调整:
+`curl -X POST 
'http://localhost:8080/loggers/org.apache.seatunnel.connectors.seatunnel.jdbc?level=DEBUG&scope=cluster'`
+
+</details>
+
+<details>
+ <summary><code>DELETE</code> <code><b>/loggers/:name</b></code> 
<code>(撤销运行时覆盖。)</code></summary>
+
+#### 请求参数
+
+> |     参数名      |   是否必填   |  类型   |                                   描述     
                               |
+> 
|----------------|--------------|---------|---------------------------------------------------------------------------|
+> | scope          |   optional   | string  | `node`(默认)只撤销接收请求的节点,`cluster` 
会撤销集群中的每个节点        |
+
+#### 响应
+
+logger 会恢复到首次被覆盖之前的状态:配置文件中配置的级别,或者在配置文件没有配置它时恢复为从父级 logger
+继承的级别。
+
+```json
+{
+  "name": "org.apache.seatunnel.connectors.seatunnel.jdbc",
+  "level": "INFO",
+  "origin": "file",
+  "node": "localhost:8080",
+  "previousLevel": "DEBUG",
+  "status": "SUCCESS"
+}
+```
+
+如果该 logger 从未通过接口被覆盖过,`status` 为 `NO_OVERRIDE`,此时不做任何修改。
+
+</details>
+
 ### 获取节点指标信息
 
 <details>
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index 7616859aef..8def490447 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -54,6 +54,12 @@
   }
   ```
 
+- **破坏性变更:运行期日志级别接口拒绝无法识别的级别**
+  - **影响范围**:SeaTunnel Engine REST API — `POST /hazelcast/rest/maps/log-level`
+  - **变更说明**:该接口此前对任何请求都返回 `200` 和 
`{"status":"SUCCESS"}`,包括无法识别的级别名(`DEBUGG`、`verbose`、不存在的级别、空值)。这类请求实际上什么都没有生效,并且无法识别的级别会以
 `null` 传给 log4j2,而 `null` 并不是"保持不变":它会清除该 logger 上显式设置的级别,于是 logger 
静默回退到父级别,root logger 则回退到 `ERROR`。现在无法识别的级别、空级别以及缺少 `level` 参数都会返回 
`400`,并在响应中列出有效级别;级别名仍然不区分大小写。
+  - **影响**:只检查 HTTP 状态码的脚本和自动化流程,对于原本就没有生效的请求,会从 `200` 变为 
`400`。能够正确识别级别的请求行为不变。
+  - **升级指南**:请传入 log4j2 
能识别的级别(`OFF`、`FATAL`、`ERROR`、`WARN`、`INFO`、`DEBUG`、`TRACE`、`ALL`,或配置中注册的自定义级别)。被拒绝请求的响应体会列出该节点接受的级别。
+
 - **破坏性变更:`Condition.of(option, null)` 不再允许**
   - **影响范围**:`seatunnel-api` — 
`org.apache.seatunnel.api.configuration.util.Condition`
   - **变更说明**:`Condition` 构造器新增校验:二元字面量操作符(如 `EQUAL`、`NOT_EQUAL`、`GREATER_THAN` 
等)的 `expectValue` 不能为 null。此前 `Condition.of(option, null)` 会被静默接受,现在会在构造时抛出 
`IllegalArgumentException`。
diff --git 
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/RestApiIT.java
 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/RestApiIT.java
index ab0accdfa3..a25362a965 100644
--- 
a/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/RestApiIT.java
+++ 
b/seatunnel-e2e/seatunnel-engine-e2e/connector-seatunnel-e2e-base/src/test/java/org/apache/seatunnel/engine/e2e/RestApiIT.java
@@ -67,6 +67,7 @@ import static 
org.apache.seatunnel.e2e.common.util.ContainerUtil.PROJECT_ROOT_PA
 import static 
org.apache.seatunnel.engine.server.rest.RestConstant.CONTEXT_PATH;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
 import static org.hamcrest.Matchers.hasKey;
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.lessThan;
@@ -246,6 +247,107 @@ public class RestApiIT {
                                         }));
     }
 
+    @Test
+    public void testLoggers() {
+        String loggersUrl =
+                HOST
+                        + 
node1Config.getEngineConfig().getHttpConfig().getPort()
+                        + 
node1Config.getEngineConfig().getHttpConfig().getContextPath()
+                        + RestConstant.REST_URL_LOGGERS;
+        // a logger of this test only, so that changing its level cannot hide 
job logs
+        String logger = "org.apache.seatunnel.engine.e2e.RestApiIT.loggers";
+
+        // the list reports every configured logger, the root logger included
+        given().get(loggersUrl)
+                .then()
+                .statusCode(200)
+                .body("node", notNullValue())
+                .body("loggers.name", hasItem("root"));
+
+        given().get(loggersUrl + "/root")
+                .then()
+                .statusCode(200)
+                .body("name", equalTo("root"))
+                .body("level", notNullValue())
+                .body("origin", equalTo("file"));
+
+        // an unknown level is rejected instead of being reported as applied
+        given().post(loggersUrl + "/" + logger + "?level=DEBUGG")
+                .then()
+                .statusCode(400)
+                .body("message", containsString("valid levels are"));
+
+        // a request without a level does not silently do nothing either
+        given().post(loggersUrl + "/" + logger)
+                .then()
+                .statusCode(400)
+                .body("message", containsString("level is required"));
+
+        given().post(loggersUrl + "?level=DEBUG")
+                .then()
+                .statusCode(400)
+                .body("message", containsString("logger name"));
+
+        given().get(loggersUrl + "?scope=galaxy")
+                .then()
+                .statusCode(400)
+                .body("message", containsString("valid scopes are"));
+
+        // the level is applied and reported as a runtime override, not as the 
file level
+        given().body("{\"level\":\"debug\"}")
+                .post(loggersUrl + "/" + logger)
+                .then()
+                .statusCode(200)
+                .body("status", equalTo("SUCCESS"))
+                .body("name", equalTo(logger))
+                .body("level", equalTo("DEBUG"))
+                .body("origin", equalTo("runtime-override"));
+
+        given().get(loggersUrl + "/" + logger)
+                .then()
+                .statusCode(200)
+                .body("level", equalTo("DEBUG"))
+                .body("origin", equalTo("runtime-override"));
+
+        // and the override can be reverted, which the legacy log-level 
endpoint cannot do
+        given().delete(loggersUrl + "/" + logger)
+                .then()
+                .statusCode(200)
+                .body("status", equalTo("SUCCESS"))
+                .body("previousLevel", equalTo("DEBUG"))
+                .body("origin", equalTo("file"));
+
+        given().delete(loggersUrl + "/" + logger)
+                .then()
+                .statusCode(200)
+                .body("status", equalTo("NO_OVERRIDE"));
+
+        // the cluster scope answers for every member
+        given().get(loggersUrl + "?scope=cluster")
+                .then()
+                .statusCode(200)
+                .body("scope", equalTo("cluster"))
+                .body("status", equalTo("SUCCESS"))
+                .body("nodes", hasSize(ports.size()));
+
+        given().post(loggersUrl + "/" + logger + "?scope=cluster&level=TRACE")
+                .then()
+                .statusCode(200)
+                .body("scope", equalTo("cluster"))
+                .body("status", equalTo("SUCCESS"))
+                .body("level", equalTo("TRACE"))
+                .body("nodes", hasSize(ports.size()))
+                .body("nodes[0].level", equalTo("TRACE"))
+                .body("nodes[0].origin", equalTo("runtime-override"));
+
+        given().delete(loggersUrl + "/" + logger + "?scope=cluster")
+                .then()
+                .statusCode(200)
+                .body("scope", equalTo("cluster"))
+                .body("status", equalTo("SUCCESS"))
+                .body("nodes[0].origin", equalTo("file"));
+    }
+
     private CheckpointMonitorService resolveCheckpointMonitorService(
             HazelcastInstanceImpl instance) {
         try {
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
index 9686960fb5..0c45ada83f 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/JettyService.java
@@ -37,6 +37,7 @@ import 
org.apache.seatunnel.engine.server.rest.servlet.CurrentNodeLogServlet;
 import org.apache.seatunnel.engine.server.rest.servlet.EncryptConfigServlet;
 import org.apache.seatunnel.engine.server.rest.servlet.FinishedJobsServlet;
 import org.apache.seatunnel.engine.server.rest.servlet.JobInfoServlet;
+import org.apache.seatunnel.engine.server.rest.servlet.LoggersServlet;
 import org.apache.seatunnel.engine.server.rest.servlet.MetricsServlet;
 import org.apache.seatunnel.engine.server.rest.servlet.OptionRulesServlet;
 import org.apache.seatunnel.engine.server.rest.servlet.OverviewServlet;
@@ -73,6 +74,7 @@ import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_FINI
 import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_GET_ALL_LOG_NAME;
 import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_JOB_INFO;
 import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_LOG;
+import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_LOGGERS;
 import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_LOGS;
 import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_METRICS;
 import static 
org.apache.seatunnel.engine.server.rest.RestConstant.REST_URL_OPEN_METRICS;
@@ -210,6 +212,7 @@ public class JettyService {
         ServletHolder currentNodeLogServlet =
                 new ServletHolder(new CurrentNodeLogServlet(nodeEngine));
         ServletHolder allLogNameServlet = new ServletHolder(new 
AllLogNameServlet(nodeEngine));
+        ServletHolder loggersServlet = new ServletHolder(new 
LoggersServlet(nodeEngine));
 
         ServletHolder metricsServlet = new ServletHolder(new 
MetricsServlet(nodeEngine));
         ServletHolder realtimeMetricsServlet =
@@ -246,6 +249,7 @@ public class JettyService {
         context.addServlet(allNodeLogServletHolder, 
convertUrlToPath(REST_URL_LOGS));
         context.addServlet(currentNodeLogServlet, 
convertUrlToPath(REST_URL_LOG));
         context.addServlet(allLogNameServlet, 
convertUrlToPath(REST_URL_GET_ALL_LOG_NAME));
+        context.addServlet(loggersServlet, convertUrlToPath(REST_URL_LOGGERS));
         context.addServlet(metricsServlet, convertUrlToPath(REST_URL_METRICS));
         context.addServlet(metricsServlet, 
convertUrlToPath(REST_URL_OPEN_METRICS));
         context.addServlet(realtimeMetricsServlet, 
convertUrlToPath(REST_URL_REALTIME_METRICS));
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/log/Log4j2HttpPostCommandProcessor.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/log/Log4j2HttpPostCommandProcessor.java
index 33b300b76f..c19d470ff8 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/log/Log4j2HttpPostCommandProcessor.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/log/Log4j2HttpPostCommandProcessor.java
@@ -18,8 +18,6 @@
 package org.apache.seatunnel.engine.server.log;
 
 import org.apache.logging.log4j.Level;
-import org.apache.logging.log4j.core.config.Configurator;
-import org.apache.logging.log4j.core.config.LoggerConfig;
 
 import com.hazelcast.internal.ascii.TextCommandService;
 import com.hazelcast.internal.ascii.rest.HttpCommandProcessor;
@@ -27,6 +25,7 @@ import com.hazelcast.internal.ascii.rest.HttpPostCommand;
 import com.hazelcast.internal.ascii.rest.HttpPostCommandProcessor;
 import com.hazelcast.internal.json.JsonObject;
 
+import static com.hazelcast.internal.ascii.rest.HttpStatusCode.SC_400;
 import static com.hazelcast.internal.ascii.rest.HttpStatusCode.SC_500;
 
 public class Log4j2HttpPostCommandProcessor extends 
HttpCommandProcessor<HttpPostCommand> {
@@ -72,19 +71,31 @@ public class Log4j2HttpPostCommandProcessor extends 
HttpCommandProcessor<HttpPos
      * <p>Request Body(application/text):
      *
      * <p>your_username&your_password&com.example.logger1&ERROR
+     *
+     * <p>An unknown level name is rejected with {@code 400} instead of being 
applied as a {@code
+     * null} level, which log4j2 silently ignores.
      */
     @SuppressWarnings("MagicNumber")
     private void setLoggerLevel(HttpPostCommand request) {
         try {
             String[] params = decodeParamsAndAuthenticate(request, 4);
             String logger = params[2];
-            String level = params[3];
-            if (LoggerConfig.ROOT.equals(logger)) {
-                Configurator.setRootLevel(Level.getLevel(level));
+            String levelName = params[3];
+            Level level = LogLevels.parse(levelName);
+            if (logger == null || logger.trim().isEmpty()) {
+                prepareResponse(SC_400, request, "Logger name is required!");
+            } else if (level == null) {
+                prepareResponse(
+                        SC_400,
+                        request,
+                        "Unknown logger level '"
+                                + levelName
+                                + "', valid levels are: "
+                                + LogLevels.validNames());
             } else {
-                Configurator.setLevel(logger, Level.getLevel(level));
+                LogLevels.apply(logger, level);
+                prepareResponse(request, new JsonObject().add("status", 
"SUCCESS"));
             }
-            prepareResponse(request, new JsonObject().add("status", 
"SUCCESS"));
         } catch (Throwable e) {
             prepareResponse(SC_500, request, exceptionResponse(e));
         }
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/log/LogLevels.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/log/LogLevels.java
new file mode 100644
index 0000000000..260cf4dc77
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/log/LogLevels.java
@@ -0,0 +1,241 @@
+/*
+ * 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.seatunnel.engine.server.log;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.Configurator;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** Parsing, applying and reverting of runtime log levels, shared by the log 
level endpoints. */
+public final class LogLevels {
+
+    /** The level of the logger comes from the log4j2 configuration file. */
+    public static final String ORIGIN_FILE = "file";
+
+    /** The level was set through a log level endpoint and is lost when the 
node restarts. */
+    public static final String ORIGIN_RUNTIME_OVERRIDE = "runtime-override";
+
+    /**
+     * State every overridden logger had before its first runtime override, so 
that {@link
+     * #reset(String)} can put it back and so that the endpoints can tell 
where the current level
+     * comes from.
+     */
+    private static final Map<String, OriginalState> OVERRIDDEN_LEVELS = new 
ConcurrentHashMap<>();
+
+    private LogLevels() {}
+
+    /**
+     * Resolves a level name to a log4j2 {@link Level}, accepting any letter 
case, or returns {@code
+     * null} when no such level is registered. Callers must reject {@code 
null} instead of handing
+     * it to {@link Configurator}: a {@code null} level does not leave the 
logger alone, it removes
+     * the explicit level so the logger falls back to its parent, and the root 
logger falls back to
+     * {@code ERROR}. Silently lowering a level is worse than answering with 
an error.
+     */
+    public static Level parse(String name) {
+        if (name == null) {
+            return null;
+        }
+        String trimmed = name.trim();
+        if (trimmed.isEmpty()) {
+            return null;
+        }
+        Level level = Level.getLevel(trimmed);
+        if (level == null) {
+            level = Level.getLevel(trimmed.toUpperCase(Locale.ROOT));
+        }
+        return level;
+    }
+
+    /**
+     * Names of all levels currently registered in log4j2, most severe first. 
{@link Level#values()}
+     * reads a hash map, so the natural order of {@link Level} is applied to 
keep the message of a
+     * rejected request stable.
+     */
+    public static String validNames() {
+        return Stream.of(Level.values())
+                .sorted()
+                .map(Level::name)
+                .collect(Collectors.joining(", "));
+    }
+
+    /**
+     * Applies a level to one logger, or to the root logger for {@link 
LoggerConfig#ROOT}, and
+     * returns the effective level it replaced. The level is read and replaced 
while holding the
+     * monitor of this class, so two requests that change the same logger at 
the same time can not
+     * both report the level that was in place before either of them ran.
+     */
+    public static synchronized Level apply(String logger, Level level) {
+        String name = trim(logger);
+        Level previousLevel = effectiveLevel(name);
+        OVERRIDDEN_LEVELS.computeIfAbsent(name, OriginalState::of);
+        if (LoggerConfig.ROOT.equals(name)) {
+            Configurator.setRootLevel(level);
+        } else {
+            Configurator.setLevel(name, level);
+        }
+        return previousLevel;
+    }
+
+    /**
+     * Reverts a logger to the state it had before its first runtime override. 
A logger that was not
+     * configured at all loses the configuration the override added and 
inherits from its parent
+     * again. Like {@link #apply(String, Level)} this runs under the monitor 
of this class, so the
+     * returned levels always belong to the same revert.
+     */
+    public static synchronized Reverted reset(String logger) {
+        String name = trim(logger);
+        Level previousLevel = effectiveLevel(name);
+        OriginalState original = OVERRIDDEN_LEVELS.remove(name);
+        if (original == null) {
+            return new Reverted(false, previousLevel, previousLevel);
+        }
+        if (LoggerConfig.ROOT.equals(name)) {
+            // the root logger is always configured, the fallback is only 
defensive
+            Configurator.setRootLevel(original.level == null ? Level.INFO : 
original.level);
+        } else if (original.configured) {
+            Configurator.setLevel(name, original.level);
+        } else {
+            LoggerContext context = LoggerContext.getContext(false);
+            context.getConfiguration().removeLogger(name);
+            context.updateLoggers();
+        }
+        return new Reverted(true, previousLevel, effectiveLevel(name));
+    }
+
+    /** Whether the current level of a logger comes from a runtime override. */
+    public static boolean isOverridden(String logger) {
+        return OVERRIDDEN_LEVELS.containsKey(trim(logger));
+    }
+
+    /** Either {@link #ORIGIN_FILE} or {@link #ORIGIN_RUNTIME_OVERRIDE} for 
one logger. */
+    public static String origin(String logger) {
+        return isOverridden(logger) ? ORIGIN_RUNTIME_OVERRIDE : ORIGIN_FILE;
+    }
+
+    /**
+     * The level a logger was configured with before its first runtime 
override, {@code null} when
+     * it was never overridden.
+     */
+    public static Level levelBeforeOverride(String logger) {
+        OriginalState original = OVERRIDDEN_LEVELS.get(trim(logger));
+        return original == null ? null : original.level;
+    }
+
+    /**
+     * Effective level of a logger name, resolved through the closest 
configured ancestor, so that
+     * names which are not configured themselves can be asked about as well.
+     */
+    public static Level effectiveLevel(String logger) {
+        return 
configuration().getLoggerConfig(configurationName(trim(logger))).getLevel();
+    }
+
+    /**
+     * Effective level of every logger of the running configuration, keyed by 
the name the endpoints
+     * use ({@link LoggerConfig#ROOT} for the root logger), in configuration 
order.
+     */
+    public static Map<String, Level> loggers() {
+        Map<String, Level> loggers = new LinkedHashMap<>();
+        configuration()
+                .getLoggers()
+                .forEach((name, config) -> loggers.put(endpointName(name), 
config.getLevel()));
+        return loggers;
+    }
+
+    private static Configuration configuration() {
+        return LoggerContext.getContext(false).getConfiguration();
+    }
+
+    /** The root logger is named {@code root} on the endpoints and {@code ""} 
inside log4j2. */
+    private static String configurationName(String logger) {
+        return LoggerConfig.ROOT.equals(logger) ? LogManager.ROOT_LOGGER_NAME 
: logger;
+    }
+
+    private static String endpointName(String configurationName) {
+        return LogManager.ROOT_LOGGER_NAME.equals(configurationName)
+                ? LoggerConfig.ROOT
+                : configurationName;
+    }
+
+    private static String trim(String logger) {
+        return logger == null ? "" : logger.trim();
+    }
+
+    /**
+     * Outcome of {@link #reset(String)}: whether anything was reverted, and 
the levels around the
+     * revert.
+     */
+    public static final class Reverted {
+
+        private final boolean reverted;
+        private final Level previousLevel;
+        private final Level level;
+
+        private Reverted(boolean reverted, Level previousLevel, Level level) {
+            this.reverted = reverted;
+            this.previousLevel = previousLevel;
+            this.level = level;
+        }
+
+        /** Whether the logger was overridden through an endpoint and has now 
been put back. */
+        public boolean isReverted() {
+            return reverted;
+        }
+
+        /** Effective level immediately before the revert. */
+        public Level getPreviousLevel() {
+            return previousLevel;
+        }
+
+        /** Effective level after the revert, the previous level when nothing 
was reverted. */
+        public Level getLevel() {
+            return level;
+        }
+    }
+
+    /** What a logger looked like in the configuration before it was 
overridden. */
+    private static final class OriginalState {
+
+        /** Whether the logger had a configuration of its own, and not only an 
inherited one. */
+        private final boolean configured;
+
+        /** Level the logger was configured with. */
+        private final Level level;
+
+        private OriginalState(boolean configured, Level level) {
+            this.configured = configured;
+            this.level = level;
+        }
+
+        private static OriginalState of(String logger) {
+            LoggerConfig config = 
configuration().getLoggers().get(configurationName(logger));
+            return config == null
+                    ? new OriginalState(false, null)
+                    : new OriginalState(true, config.getLevel());
+        }
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/RestConstant.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/RestConstant.java
index 11964f1686..883a16c5e3 100644
--- 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/RestConstant.java
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/RestConstant.java
@@ -111,6 +111,8 @@ public class RestConstant {
     public static final String REST_URL_LOG = "/log";
     // Code internal Use , Get Node Log Name
     public static final String REST_URL_GET_ALL_LOG_NAME = "/get-all-log-name";
+    // Read and change runtime log levels
+    public static final String REST_URL_LOGGERS = "/loggers";
     public static final String REST_URL_METRICS = "/metrics";
     public static final String REST_URL_OPEN_METRICS = "/openmetrics";
     public static final String REST_URL_TRACE_TASK_MAPPING = 
"/trace/task-mapping";
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/LoggerLevelService.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/LoggerLevelService.java
new file mode 100644
index 0000000000..628bc79336
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/LoggerLevelService.java
@@ -0,0 +1,301 @@
+/*
+ * 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.seatunnel.engine.server.rest.service;
+
+import org.apache.seatunnel.engine.common.config.server.HttpConfig;
+import org.apache.seatunnel.engine.server.SeaTunnelServer;
+import org.apache.seatunnel.engine.server.log.LogLevels;
+import org.apache.seatunnel.engine.server.operation.GetNodeHttpPortOperation;
+import org.apache.seatunnel.engine.server.rest.RestConstant;
+import org.apache.seatunnel.engine.server.utils.NodeEngineUtil;
+
+import org.apache.logging.log4j.Level;
+
+import com.hazelcast.cluster.Member;
+import com.hazelcast.internal.json.Json;
+import com.hazelcast.internal.json.JsonArray;
+import com.hazelcast.internal.json.JsonObject;
+import com.hazelcast.spi.impl.NodeEngineImpl;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+
+/**
+ * Reads and changes log levels of the node that serves the request, and 
optionally of every member
+ * of the cluster. Runtime overrides are node local and are lost when a node 
restarts, so the
+ * cluster scope is a convenience for the operator and not a replicated 
setting.
+ */
+@Slf4j
+public class LoggerLevelService extends BaseService {
+
+    public static final String SCOPE = "scope";
+    public static final String SCOPE_CLUSTER = "cluster";
+    public static final String SCOPE_NODE = "node";
+    public static final String LEVEL = "level";
+
+    private static final String NODE = "node";
+    private static final String NODES = "nodes";
+    private static final String LOGGERS = "loggers";
+    private static final String NAME = "name";
+    private static final String ORIGIN = "origin";
+    private static final String FILE_LEVEL = "fileLevel";
+    private static final String PREVIOUS_LEVEL = "previousLevel";
+    private static final String STATUS = "status";
+    private static final String ERROR = "error";
+    private static final String SUCCESS = "SUCCESS";
+    private static final String FAILURE = "FAILURE";
+    private static final String PARTIAL_FAILURE = "PARTIAL_FAILURE";
+    private static final String NO_OVERRIDE = "NO_OVERRIDE";
+    private static final int REQUEST_TIMEOUT_MS = 5000;
+
+    public LoggerLevelService(NodeEngineImpl nodeEngine) {
+        super(nodeEngine);
+    }
+
+    /** Every logger of the running configuration of this node. */
+    public JsonObject loggers() {
+        JsonArray loggers = new JsonArray();
+        for (Map.Entry<String, Level> logger : LogLevels.loggers().entrySet()) 
{
+            loggers.add(loggerJson(logger.getKey(), logger.getValue()));
+        }
+        return new JsonObject().add(NODE, nodeId()).add(LOGGERS, loggers);
+    }
+
+    /** One logger of this node, resolved through its closest configured 
ancestor. */
+    public JsonObject logger(String name) {
+        return loggerJson(name, LogLevels.effectiveLevel(name)).add(NODE, 
nodeId());
+    }
+
+    /**
+     * Overrides the level of one logger on this node. The level that was 
replaced comes from the
+     * change itself; the rest of the answer is read afterwards, so a change 
another request makes
+     * in between is already reflected in it.
+     */
+    public JsonObject setLevel(String name, Level level, String client) {
+        Level previousLevel = LogLevels.apply(name, level);
+        log.info(
+                "Logger level changed: logger={}, {} -> {}, scope={}, 
client={}",
+                name,
+                previousLevel,
+                level,
+                SCOPE_NODE,
+                client);
+        return loggerJson(name, LogLevels.effectiveLevel(name))
+                .add(NODE, nodeId())
+                .add(PREVIOUS_LEVEL, previousLevel == null ? null : 
previousLevel.name())
+                .add(STATUS, SUCCESS);
+    }
+
+    /** Reverts one logger of this node to the level it had before the first 
override. */
+    public JsonObject resetLevel(String name, String client) {
+        LogLevels.Reverted reverted = LogLevels.reset(name);
+        Level previousLevel = reverted.getPreviousLevel();
+        if (reverted.isReverted()) {
+            log.info(
+                    "Logger level reverted: logger={}, {} -> {}, scope={}, 
client={}",
+                    name,
+                    previousLevel,
+                    reverted.getLevel(),
+                    SCOPE_NODE,
+                    client);
+        }
+        return loggerJson(name, reverted.getLevel())
+                .add(NODE, nodeId())
+                .add(PREVIOUS_LEVEL, previousLevel == null ? null : 
previousLevel.name())
+                .add(STATUS, reverted.isReverted() ? SUCCESS : NO_OVERRIDE);
+    }
+
+    /** Every logger of every member of the cluster. */
+    public JsonObject clusterLoggers(String name) {
+        return fanOut("GET", name, null);
+    }
+
+    /** Overrides the level of one logger on every member of the cluster. */
+    public JsonObject clusterSetLevel(String name, Level level, String client) 
{
+        log.info(
+                "Logger level change requested for the whole cluster: 
logger={}, level={}, "
+                        + "client={}",
+                name,
+                level,
+                client);
+        return fanOut("POST", name, level);
+    }
+
+    /** Reverts one logger to its pre-override level on every member of the 
cluster. */
+    public JsonObject clusterResetLevel(String name, String client) {
+        log.info(
+                "Logger level revert requested for the whole cluster: 
logger={}, client={}",
+                name,
+                client);
+        return fanOut("DELETE", name, null);
+    }
+
+    /**
+     * Runs the node local endpoint of every member and collects the answers. 
Members are
+     * independent of each other, so a member that cannot be reached does not 
undo the change on the
+     * members that were already changed; the per member status makes that 
visible instead.
+     */
+    private JsonObject fanOut(String method, String name, Level level) {
+        HttpConfig httpConfig = httpConfig();
+        JsonArray nodes = new JsonArray();
+        int reached = 0;
+        int failed = 0;
+        for (Member member : nodeEngine.getClusterService().getMembers()) {
+            String memberId = member.getAddress().getHost();
+            try {
+                int httpPort =
+                        (int)
+                                NodeEngineUtil.sendOperationToMemberNode(
+                                                nodeEngine,
+                                                new GetNodeHttpPortOperation(),
+                                                member.getAddress())
+                                        .get();
+                memberId = member.getAddress().getHost() + ":" + httpPort;
+                String url =
+                        "http://";
+                                + member.getAddress().getHost()
+                                + ":"
+                                + httpPort
+                                + httpConfig.getContextPath()
+                                + RestConstant.REST_URL_LOGGERS
+                                + (name == null ? "" : "/" + encode(name))
+                                + (level == null ? "" : "?" + LEVEL + "=" + 
encode(level.name()));
+                nodes.add(request(url, method, httpConfig));
+                reached++;
+            } catch (Throwable t) {
+                log.warn("Logger level request to member {} failed", memberId, 
t);
+                nodes.add(
+                        new JsonObject()
+                                .add(NODE, memberId)
+                                .add(STATUS, FAILURE)
+                                .add(ERROR, errorMessage(t)));
+                failed++;
+            }
+        }
+        String status = failed == 0 ? SUCCESS : (reached == 0 ? FAILURE : 
PARTIAL_FAILURE);
+        JsonObject response = new JsonObject().add(SCOPE, 
SCOPE_CLUSTER).add(STATUS, status);
+        if (name != null) {
+            response.add(NAME, name);
+        }
+        if (level != null) {
+            response.add(LEVEL, level.name());
+        }
+        return response.add(NODES, nodes);
+    }
+
+    private JsonObject request(String url, String method, HttpConfig 
httpConfig)
+            throws IOException {
+        HttpURLConnection connection = (HttpURLConnection) new 
URL(url).openConnection();
+        try {
+            connection.setRequestMethod(method);
+            connection.setConnectTimeout(REQUEST_TIMEOUT_MS);
+            connection.setReadTimeout(REQUEST_TIMEOUT_MS);
+            if (httpConfig.isEnableBasicAuth()) {
+                String credentials =
+                        httpConfig.getBasicAuthUsername() + ":" + 
httpConfig.getBasicAuthPassword();
+                connection.setRequestProperty(
+                        "Authorization",
+                        "Basic "
+                                + Base64.getEncoder()
+                                        .encodeToString(
+                                                
credentials.getBytes(StandardCharsets.UTF_8)));
+            }
+            connection.connect();
+            int code = connection.getResponseCode();
+            if (code != HttpURLConnection.HTTP_OK) {
+                throw new IOException(
+                        "HTTP " + code + " from " + url + ": " + 
read(connection.getErrorStream()));
+            }
+            return Json.parse(read(connection.getInputStream())).asObject();
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    private JsonObject loggerJson(String name, Level level) {
+        JsonObject logger =
+                new JsonObject()
+                        .add(NAME, name)
+                        .add(LEVEL, level == null ? null : level.name())
+                        .add(ORIGIN, LogLevels.origin(name));
+        Level fileLevel = LogLevels.levelBeforeOverride(name);
+        if (fileLevel != null) {
+            logger.add(FILE_LEVEL, fileLevel.name());
+        }
+        return logger;
+    }
+
+    private HttpConfig httpConfig() {
+        SeaTunnelServer seaTunnelServer = getSeaTunnelServer(false);
+        if (seaTunnelServer == null) {
+            throw new IllegalStateException("SeaTunnel server is not available 
on this node.");
+        }
+        return 
seaTunnelServer.getSeaTunnelConfig().getEngineConfig().getHttpConfig();
+    }
+
+    private String nodeId() {
+        return nodeEngine.getThisAddress().getHost() + ":" + 
httpConfig().getPort();
+    }
+
+    /**
+     * Short reason of a failed member request, the stack trace goes to the 
node log instead. The
+     * root cause is reported, because the wrappers a failed operation 
collects on the way out
+     * ({@code CompletionException} around an {@code ExecutionException} 
around the real failure)
+     * say nothing about what went wrong.
+     */
+    private static String errorMessage(Throwable t) {
+        Throwable cause = t;
+        while (cause.getCause() != null && cause.getCause() != cause) {
+            cause = cause.getCause();
+        }
+        return cause.getMessage() == null
+                ? cause.getClass().getName()
+                : cause.getClass().getSimpleName() + ": " + cause.getMessage();
+    }
+
+    private static String encode(String value) {
+        try {
+            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Can not encode '" + value + 
"'", e);
+        }
+    }
+
+    private static String read(InputStream stream) throws IOException {
+        if (stream == null) {
+            return "";
+        }
+        try (InputStream input = stream;
+                ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+            byte[] buffer = new byte[4096];
+            int length;
+            while ((length = input.read(buffer)) != -1) {
+                output.write(buffer, 0, length);
+            }
+            return output.toString(StandardCharsets.UTF_8.name());
+        }
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/servlet/LoggersServlet.java
 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/servlet/LoggersServlet.java
new file mode 100644
index 0000000000..bc695b47ec
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/servlet/LoggersServlet.java
@@ -0,0 +1,186 @@
+/*
+ * 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.seatunnel.engine.server.rest.servlet;
+
+import org.apache.seatunnel.engine.server.log.LogLevels;
+import org.apache.seatunnel.engine.server.rest.service.LoggerLevelService;
+
+import org.apache.logging.log4j.Level;
+
+import com.hazelcast.internal.json.Json;
+import com.hazelcast.internal.json.JsonObject;
+import com.hazelcast.internal.json.JsonValue;
+import com.hazelcast.spi.impl.NodeEngineImpl;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Reads and changes runtime log levels.
+ *
+ * <p>{@code GET /loggers} lists every configured logger, {@code GET 
/loggers/{name}} reads one,
+ * {@code POST /loggers/{name}} overrides its level and {@code DELETE 
/loggers/{name}} reverts the
+ * override. {@code ?scope=cluster} runs the same request on every member of 
the cluster.
+ */
+public class LoggersServlet extends BaseServlet {
+
+    private final LoggerLevelService loggerLevelService;
+
+    public LoggersServlet(NodeEngineImpl nodeEngine) {
+        super(nodeEngine);
+        this.loggerLevelService = new LoggerLevelService(nodeEngine);
+    }
+
+    @Override
+    protected void doGet(HttpServletRequest req, HttpServletResponse resp) 
throws IOException {
+        String name = loggerName(req);
+        if (!isScopeValid(req, resp)) {
+            return;
+        }
+        if (isClusterRequested(req)) {
+            writeJson(resp, loggerLevelService.clusterLoggers(name));
+        } else if (name == null) {
+            writeJson(resp, loggerLevelService.loggers());
+        } else {
+            writeJson(resp, loggerLevelService.logger(name));
+        }
+    }
+
+    @Override
+    protected void doPost(HttpServletRequest req, HttpServletResponse resp) 
throws IOException {
+        String name = loggerName(req);
+        if (name == null) {
+            badRequest(resp, "The logger name must not be empty, use 
/loggers/{name}.");
+            return;
+        }
+        if (!isScopeValid(req, resp)) {
+            return;
+        }
+        String levelName = requestedLevel(req);
+        if (levelName == null) {
+            badRequest(
+                    resp,
+                    "The level is required, send it as ?level=DEBUG or as 
{\"level\":\"DEBUG\"}.");
+            return;
+        }
+        Level level = LogLevels.parse(levelName);
+        if (level == null) {
+            badRequest(
+                    resp,
+                    "Unknown logger level '"
+                            + levelName
+                            + "', valid levels are: "
+                            + LogLevels.validNames());
+            return;
+        }
+        String client = req.getRemoteAddr();
+        if (isClusterRequested(req)) {
+            writeJson(resp, loggerLevelService.clusterSetLevel(name, level, 
client));
+        } else {
+            writeJson(resp, loggerLevelService.setLevel(name, level, client));
+        }
+    }
+
+    @Override
+    protected void doDelete(HttpServletRequest req, HttpServletResponse resp) 
throws IOException {
+        String name = loggerName(req);
+        if (name == null) {
+            badRequest(resp, "The logger name must not be empty, use 
/loggers/{name}.");
+            return;
+        }
+        if (!isScopeValid(req, resp)) {
+            return;
+        }
+        String client = req.getRemoteAddr();
+        if (isClusterRequested(req)) {
+            writeJson(resp, loggerLevelService.clusterResetLevel(name, 
client));
+        } else {
+            writeJson(resp, loggerLevelService.resetLevel(name, client));
+        }
+    }
+
+    /** The logger name of the request path, {@code null} when the whole list 
is addressed. */
+    private String loggerName(HttpServletRequest req) {
+        String pathInfo = req.getPathInfo();
+        if (pathInfo == null) {
+            return null;
+        }
+        String name = pathInfo.startsWith("/") ? pathInfo.substring(1) : 
pathInfo;
+        name = name.trim();
+        return name.isEmpty() ? null : name;
+    }
+
+    /**
+     * The level of the {@code level} query parameter, falling back to the 
{@code level} field of
+     * the request body, or {@code null} when neither carries one.
+     */
+    private String requestedLevel(HttpServletRequest req) throws IOException {
+        String level = req.getParameter(LoggerLevelService.LEVEL);
+        if (level != null) {
+            return level;
+        }
+        byte[] body = requestBody(req);
+        if (body.length == 0) {
+            return null;
+        }
+        try {
+            JsonValue json = Json.parse(new String(body, 
StandardCharsets.UTF_8));
+            return json.isObject()
+                    ? json.asObject().getString(LoggerLevelService.LEVEL, null)
+                    : null;
+        } catch (RuntimeException e) {
+            // an unparsable body is reported as a missing level instead of a 
server error
+            return null;
+        }
+    }
+
+    private boolean isClusterRequested(HttpServletRequest req) {
+        return LoggerLevelService.SCOPE_CLUSTER.equalsIgnoreCase(
+                req.getParameter(LoggerLevelService.SCOPE));
+    }
+
+    /** Rejects an unknown scope and returns whether the request may go on. */
+    private boolean isScopeValid(HttpServletRequest req, HttpServletResponse 
resp)
+            throws IOException {
+        String scope = req.getParameter(LoggerLevelService.SCOPE);
+        if (scope == null
+                || LoggerLevelService.SCOPE_CLUSTER.equalsIgnoreCase(scope)
+                || LoggerLevelService.SCOPE_NODE.equalsIgnoreCase(scope)) {
+            return true;
+        }
+        badRequest(
+                resp,
+                "Unknown scope '"
+                        + scope
+                        + "', valid scopes are: "
+                        + LoggerLevelService.SCOPE_NODE
+                        + ", "
+                        + LoggerLevelService.SCOPE_CLUSTER);
+        return false;
+    }
+
+    private void badRequest(HttpServletResponse resp, String message) throws 
IOException {
+        writeJson(
+                resp,
+                new JsonObject().add("status", "fail").add("message", message),
+                HttpServletResponse.SC_BAD_REQUEST);
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/log/LogLevelsTest.java
 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/log/LogLevelsTest.java
new file mode 100644
index 0000000000..2b0e90fe45
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/log/LogLevelsTest.java
@@ -0,0 +1,209 @@
+/*
+ * 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.seatunnel.engine.server.log;
+
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.config.Configurator;
+import org.apache.logging.log4j.core.config.LoggerConfig;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+public class LogLevelsTest {
+
+    /** A logger name owned by this test only, so mutating its level cannot 
affect other tests. */
+    private static final String TEST_LOGGER =
+            "org.apache.seatunnel.engine.server.log.LogLevelsTest";
+
+    @AfterEach
+    void revertOverrides() {
+        LogLevels.reset(TEST_LOGGER);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"DEBUG", "debug", "Debug", " DEBUG ", "\tdebug\n"})
+    void testLevelNameIsParsedCaseInsensitively(String name) {
+        Assertions.assertEquals(Level.DEBUG, LogLevels.parse(name));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"DEBUGG", "verbose", "1", "INFO,DEBUG"})
+    void testUnknownLevelNameIsRejected(String name) {
+        // Level.getLevel returns null instead of throwing, so an unchecked 
value would reach
+        // Configurator as a null level, which clears the level of the logger 
instead of leaving it
+        // alone while the caller is told the request succeeded
+        Assertions.assertNull(LogLevels.parse(name));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"", " ", "\t"})
+    void testBlankLevelNameIsRejected(String name) {
+        Assertions.assertNull(LogLevels.parse(name));
+    }
+
+    @Test
+    void testNullLevelNameIsRejected() {
+        Assertions.assertNull(LogLevels.parse(null));
+    }
+
+    @Test
+    void testValidNamesListsEveryStandardLevel() {
+        String validNames = LogLevels.validNames();
+        for (Level level : Level.values()) {
+            Assertions.assertTrue(
+                    validNames.contains(level.name()),
+                    () -> "Level " + level.name() + " missing from: " + 
validNames);
+        }
+    }
+
+    @Test
+    void testValidNamesAreOrderedBySeverity() {
+        // Level.values() reads a hash map, so without an explicit order the 
same rejected request
+        // would list the valid levels differently between runs
+        String validNames = LogLevels.validNames();
+        List<Level> bySeverity =
+                Arrays.asList(
+                        Level.OFF,
+                        Level.FATAL,
+                        Level.ERROR,
+                        Level.WARN,
+                        Level.INFO,
+                        Level.DEBUG,
+                        Level.TRACE,
+                        Level.ALL);
+        int previousIndex = -1;
+        for (Level level : bySeverity) {
+            int index = validNames.indexOf(level.name());
+            Assertions.assertTrue(
+                    index > previousIndex,
+                    () -> level.name() + " is out of severity order in: " + 
validNames);
+            previousIndex = index;
+        }
+    }
+
+    @Test
+    void testApplyChangesTheEffectiveLevel() {
+        Level originalLevel = LogManager.getLogger(TEST_LOGGER).getLevel();
+        Level target = originalLevel == Level.TRACE ? Level.ERROR : 
Level.TRACE;
+
+        Level replaced = LogLevels.apply(TEST_LOGGER, target);
+
+        Assertions.assertEquals(target, 
LogManager.getLogger(TEST_LOGGER).getLevel());
+        // the level that was replaced is reported by the change itself, so an 
audit line and a
+        // previousLevel field can not be built from a level a concurrent 
change already replaced
+        Assertions.assertEquals(originalLevel, replaced);
+    }
+
+    @Test
+    void testOverrideOfAnUnconfiguredLoggerIsTrackedAndReverted() {
+        String logger = TEST_LOGGER + ".unconfigured";
+        Level inherited = LogLevels.effectiveLevel(logger);
+        Assertions.assertEquals(LogLevels.ORIGIN_FILE, 
LogLevels.origin(logger));
+
+        LogLevels.apply(logger, Level.TRACE);
+
+        Assertions.assertEquals(Level.TRACE, LogLevels.effectiveLevel(logger));
+        Assertions.assertEquals(LogLevels.ORIGIN_RUNTIME_OVERRIDE, 
LogLevels.origin(logger));
+        // the logger had no configuration of its own, so there is no file 
level to report
+        Assertions.assertNull(LogLevels.levelBeforeOverride(logger));
+
+        LogLevels.Reverted reverted = LogLevels.reset(logger);
+
+        Assertions.assertTrue(reverted.isReverted());
+        Assertions.assertEquals(Level.TRACE, reverted.getPreviousLevel());
+        Assertions.assertEquals(inherited, reverted.getLevel());
+        Assertions.assertEquals(inherited, LogLevels.effectiveLevel(logger));
+        Assertions.assertEquals(LogLevels.ORIGIN_FILE, 
LogLevels.origin(logger));
+        Assertions.assertFalse(LogLevels.isOverridden(logger));
+    }
+
+    @Test
+    void testConfiguredLevelIsRememberedAndRestored() {
+        String logger = TEST_LOGGER + ".configured";
+        // stands in for a logger that log4j2.properties configures with a 
level of its own
+        Configurator.setLevel(logger, Level.WARN);
+
+        LogLevels.apply(logger, Level.DEBUG);
+
+        Assertions.assertEquals(Level.DEBUG, LogLevels.effectiveLevel(logger));
+        Assertions.assertEquals(Level.WARN, 
LogLevels.levelBeforeOverride(logger));
+
+        // a second override must not forget the level the file configured
+        LogLevels.apply(logger, Level.TRACE);
+        Assertions.assertEquals(Level.WARN, 
LogLevels.levelBeforeOverride(logger));
+
+        LogLevels.Reverted reverted = LogLevels.reset(logger);
+
+        Assertions.assertTrue(reverted.isReverted());
+        Assertions.assertEquals(Level.TRACE, reverted.getPreviousLevel());
+        Assertions.assertEquals(Level.WARN, reverted.getLevel());
+        Assertions.assertEquals(Level.WARN, LogLevels.effectiveLevel(logger));
+        Assertions.assertEquals(LogLevels.ORIGIN_FILE, 
LogLevels.origin(logger));
+        Assertions.assertNull(LogLevels.levelBeforeOverride(logger));
+    }
+
+    @Test
+    void testResetOfALoggerThatWasNeverOverriddenReportsNoChange() {
+        String logger = TEST_LOGGER + ".neverTouched";
+        Level inherited = LogLevels.effectiveLevel(logger);
+
+        LogLevels.Reverted reverted = LogLevels.reset(logger);
+
+        Assertions.assertFalse(reverted.isReverted());
+        // nothing changed, so both levels are the one the logger already had
+        Assertions.assertEquals(inherited, reverted.getPreviousLevel());
+        Assertions.assertEquals(inherited, reverted.getLevel());
+    }
+
+    @Test
+    void testRootLoggerCanBeOverriddenAndReverted() {
+        Level configuredRootLevel = 
LogLevels.effectiveLevel(LoggerConfig.ROOT);
+        Level target = configuredRootLevel == Level.TRACE ? Level.ERROR : 
Level.TRACE;
+        try {
+            LogLevels.apply(LoggerConfig.ROOT, target);
+
+            Assertions.assertEquals(target, 
LogLevels.effectiveLevel(LoggerConfig.ROOT));
+            Assertions.assertEquals(
+                    LogLevels.ORIGIN_RUNTIME_OVERRIDE, 
LogLevels.origin(LoggerConfig.ROOT));
+            Assertions.assertEquals(
+                    configuredRootLevel, 
LogLevels.levelBeforeOverride(LoggerConfig.ROOT));
+        } finally {
+            
Assertions.assertTrue(LogLevels.reset(LoggerConfig.ROOT).isReverted());
+        }
+        Assertions.assertEquals(configuredRootLevel, 
LogLevels.effectiveLevel(LoggerConfig.ROOT));
+    }
+
+    @Test
+    void testLoggersListReportsTheRootLoggerUnderItsEndpointName() {
+        Map<String, Level> loggers = LogLevels.loggers();
+
+        Assertions.assertTrue(
+                loggers.containsKey(LoggerConfig.ROOT),
+                () -> "root logger missing from: " + loggers.keySet());
+        
Assertions.assertFalse(loggers.containsKey(LogManager.ROOT_LOGGER_NAME));
+        Assertions.assertNotNull(loggers.get(LoggerConfig.ROOT));
+    }
+}

Reply via email to