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

markap14 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 26f5fa2be0 NIFI-11443 Route Python Framework Logging to SLF4J (#8407)
26f5fa2be0 is described below

commit 26f5fa2be044accc6ae8403daf62be621e47f33e
Author: David Handermann <[email protected]>
AuthorDate: Tue Mar 5 15:55:04 2024 -0600

    NIFI-11443 Route Python Framework Logging to SLF4J (#8407)
    
    * NIFI-11443 Routed Python Framework Logging to SLF4J
    
    - Changed Python logging to use standard output stream
    - Adjusted Python logging format for simplified processing
    - Updated PythonProcess to pipe standard error and standard output streams 
to reader
    - Added Log Reader command with Virtual Thread for each Python Process
    - Removed Python log properties from NiFi Properties configuration
---
 .../java/org/apache/nifi/util/NiFiProperties.java  |   2 -
 .../src/main/asciidoc/administration-guide.adoc    |   3 -
 .../org/apache/nifi/controller/FlowController.java |   4 -
 .../nifi-framework/nifi-resources/pom.xml          |   1 -
 .../src/main/resources/conf/logback.xml            |   3 +
 .../src/main/resources/conf/nifi.properties        |   1 -
 .../nifi-py4j-bundle/nifi-py4j-bridge/pom.xml      |   6 +
 .../java/org/apache/nifi/py4j/PythonProcess.java   |  46 ++++-
 .../apache/nifi/py4j/PythonProcessLogReader.java   | 176 +++++++++++++++++
 .../org/apache/nifi/py4j/StandardPythonBridge.java |   6 +
 .../nifi/py4j/logback/LevelChangeListener.java     | 145 ++++++++++++++
 .../nifi/py4j/logging/LogLevelChangeHandler.java   |  37 ++++
 .../nifi/py4j/logging/LogLevelChangeListener.java  |  32 +++
 .../apache/nifi/py4j/logging/PythonLogLevel.java   |  67 +++++++
 .../logging/StandardLogLevelChangeHandler.java     | 100 ++++++++++
 .../nifi/py4j/PythonProcessLogReaderTest.java      | 220 +++++++++++++++++++++
 .../nifi/py4j/logback/LevelChangeListenerTest.java |  67 +++++++
 .../PythonControllerInteractionIT.java             |   1 -
 .../org/apache/nifi/python/PythonController.java   |   8 +
 .../apache/nifi/python/PythonProcessConfig.java    |  25 ---
 .../src/main/python/framework/Controller.py        |  19 +-
 .../resources/conf/clustered/node1/nifi.properties |   1 -
 .../resources/conf/clustered/node2/nifi.properties |   1 -
 .../test/resources/conf/default/nifi.properties    |   1 -
 .../src/test/resources/conf/pythonic/logback.xml   |   3 +
 .../test/resources/conf/pythonic/nifi.properties   |   1 -
 26 files changed, 919 insertions(+), 57 deletions(-)

diff --git 
a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
 
b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
index 7109600631..01dd887190 100644
--- 
a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
+++ 
b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
@@ -319,7 +319,6 @@ public class NiFiProperties extends ApplicationProperties {
     public static final String PYTHON_FRAMEWORK_SOURCE_DIRECTORY = 
"nifi.python.framework.source.directory";
     public static final String PYTHON_EXTENSION_DIRECTORY_PREFIX = 
"nifi.python.extensions.source.directory.";
     public static final String PYTHON_WORKING_DIRECTORY = 
"nifi.python.working.directory";
-    public static final String PYTHON_LOGS_DIRECTORY = 
"nifi.python.logs.directory";
     public static final String PYTHON_MAX_PROCESSES = 
"nifi.python.max.processes";
     public static final String PYTHON_MAX_PROCESSES_PER_TYPE = 
"nifi.python.max.processes.per.extension.type";
     public static final String PYTHON_COMMS_TIMEOUT = 
"nifi.python.comms.timeout";
@@ -327,7 +326,6 @@ public class NiFiProperties extends ApplicationProperties {
     public static final String PYTHON_CONTROLLER_DEBUGPY_ENABLED = 
"nifi.python.controller.debugpy.enabled";
     public static final String PYTHON_CONTROLLER_DEBUGPY_PORT = 
"nifi.python.controller.debugpy.port";
     public static final String PYTHON_CONTROLLER_DEBUGPY_HOST = 
"nifi.python.controller.debugpy.host";
-    public static final String PYTHON_CONTROLLER_DEBUGPY_LOGS_DIR = 
"nifi.python.controller.debugpy.logs.directory";
 
     // kubernetes properties
     public static final String CLUSTER_LEADER_ELECTION_KUBERNETES_LEASE_PREFIX 
= "nifi.cluster.leader.election.kubernetes.lease.prefix";
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc 
b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index db31ffa366..72e073c652 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -247,9 +247,6 @@ located as a sibling of this directory. For example, if the 
value of this proper
 by default, but multiple Python Extension directories can be added by adding 
additional properties with the prefix 
`nifi.python.extensions.source.directory.`.
 | nifi.python.working.directory | ./work/python | The working directory where 
NiFi should store artifacts, such as any third-party libraries that are 
downloaded as dependencies
 for the Python Processors.
-| nifi.python.logs.directory | ./logs | The directory where NiFi should store 
the logs for the Python processes. If Python Processors use the in-built 
logger, the messages will be logged to the
-app-log. However, if they create their own Python logger, the messages will be 
written to the Python-specific log file(s). Additionally, the Python framework 
will write to the Python-specific log
-file(s).
 | nifi.python.max.processes.per.extension.type | 10 | The maximum number of 
Python processes that should be spawned for any one type of Processor. Because 
Python does not scale vertically,
 adding many NiFi Processors within the same Python process would yield very 
poor performance. Instead, NiFi creates a Python process for every Python 
Processor that is added to the canvas,
 within limits. This property indicates the maximum number of Python processes 
that can be created for any particular type of Processor. For example, if there 
are 5 instances of the
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
index 17e8c9e013..727f71f25f 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
@@ -871,7 +871,6 @@ public class FlowController implements 
ReportingTaskProvider, FlowAnalysisRulePr
         final File pythonFrameworkSourceDirectory = 
nifiProperties.getPythonFrameworkSourceDirectory();
         final List<File> pythonExtensionsDirectories = 
nifiProperties.getPythonExtensionsDirectories();
         final File pythonWorkingDirectory = new 
File(nifiProperties.getProperty(NiFiProperties.PYTHON_WORKING_DIRECTORY));
-        final File pythonLogsDirectory = new 
File(nifiProperties.getProperty(NiFiProperties.PYTHON_LOGS_DIRECTORY));
 
         int maxProcesses = 
nifiProperties.getIntegerProperty(NiFiProperties.PYTHON_MAX_PROCESSES, 20);
         int maxProcessesPerType = 
nifiProperties.getIntegerProperty(NiFiProperties.PYTHON_MAX_PROCESSES_PER_TYPE, 
2);
@@ -879,7 +878,6 @@ public class FlowController implements 
ReportingTaskProvider, FlowAnalysisRulePr
         final boolean enableControllerDebug = 
Boolean.parseBoolean(nifiProperties.getProperty(NiFiProperties.PYTHON_CONTROLLER_DEBUGPY_ENABLED,
 "false"));
         final int debugPort = 
nifiProperties.getIntegerProperty(NiFiProperties.PYTHON_CONTROLLER_DEBUGPY_PORT,
 5678);
         final String debugHost = 
nifiProperties.getProperty(NiFiProperties.PYTHON_CONTROLLER_DEBUGPY_HOST, 
"localhost");
-        final String debugLogs = 
nifiProperties.getProperty(NiFiProperties.PYTHON_CONTROLLER_DEBUGPY_LOGS_DIR, 
"logs");
 
         // Validate configuration for max numbers of processes.
         if (maxProcessesPerType < 1) {
@@ -906,7 +904,6 @@ public class FlowController implements 
ReportingTaskProvider, FlowAnalysisRulePr
             .pythonCommand(pythonCommand)
             .pythonFrameworkDirectory(pythonFrameworkSourceDirectory)
             .pythonExtensionsDirectories(pythonExtensionsDirectories)
-            .pythonLogsDirectory(pythonLogsDirectory)
             .pythonWorkingDirectory(pythonWorkingDirectory)
             .commsTimeout(commsTimeout == null ? null : 
Duration.ofMillis(FormatUtils.getTimeDuration(commsTimeout, 
TimeUnit.MILLISECONDS)))
             .maxPythonProcesses(maxProcesses)
@@ -914,7 +911,6 @@ public class FlowController implements 
ReportingTaskProvider, FlowAnalysisRulePr
             .enableControllerDebug(enableControllerDebug)
             .debugPort(debugPort)
             .debugHost(debugHost)
-            .debugLogsDirectory(new File(debugLogs))
             .build();
 
         final ControllerServiceTypeLookup serviceTypeLookup = 
serviceProvider::getControllerServiceType;
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
index b973224863..56aeebfd4a 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
@@ -268,7 +268,6 @@
         
<nifi.python.working.directory>./work/python</nifi.python.working.directory>
         <nifi.python.max.processes>100</nifi.python.max.processes>
         
<nifi.python.max.processes.per.extension.type>10</nifi.python.max.processes.per.extension.type>
-        <nifi.python.logs.dir>./logs</nifi.python.logs.dir>
         
         <nifi.cluster.leader.election.kubernetes.lease.prefix />
 
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml
index ae33e0484e..cbeff47ef6 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml
@@ -174,6 +174,9 @@
     <logger 
name="org.apache.nifi.controller.repository.StandardProcessSession" 
level="WARN" />
     <logger name="org.apache.parquet.hadoop.InternalParquetRecordReader" 
level="WARN" />
 
+    <!-- Py4J set to WARN to avoid verbose socket communication messages -->
+    <logger name="py4j" level="WARN" />
+
     <logger name="org.apache.zookeeper.ClientCnxn" level="ERROR" />
     <logger name="org.apache.zookeeper.server.NIOServerCnxn" level="ERROR" />
     <logger name="org.apache.zookeeper.server.NIOServerCnxnFactory" 
level="ERROR" />
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
index 922e4f9dea..3147152535 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
@@ -49,7 +49,6 @@ 
nifi.python.extensions.source.directory.default=${nifi.python.extensions.source.
 nifi.python.working.directory=${nifi.python.working.directory}
 nifi.python.max.processes=${nifi.python.max.processes}
 
nifi.python.max.processes.per.extension.type=${nifi.python.max.processes.per.extension.type}
-nifi.python.logs.directory=${nifi.python.logs.dir}
 
 ####################
 # State Management #
diff --git a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/pom.xml 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/pom.xml
index 655abc75f2..c7c05cad9f 100644
--- a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/pom.xml
+++ b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/pom.xml
@@ -42,6 +42,12 @@
             <artifactId>py4j</artifactId>
             <version>0.10.9.7</version>
         </dependency>
+        <!-- Logback provided dependency for Log Level Change notification -->
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <scope>provided</scope>
+        </dependency>
 
         <dependency>
             <groupId>org.apache.nifi</groupId>
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/PythonProcess.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/PythonProcess.java
index 2180681c0d..f34199b5bc 100644
--- 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/PythonProcess.java
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/PythonProcess.java
@@ -17,6 +17,10 @@
 
 package org.apache.nifi.py4j;
 
+import org.apache.nifi.py4j.logging.LogLevelChangeListener;
+import org.apache.nifi.py4j.logging.PythonLogLevel;
+import org.apache.nifi.py4j.logging.StandardLogLevelChangeHandler;
+import org.apache.nifi.logging.LogLevel;
 import org.apache.nifi.py4j.client.JavaObjectBindings;
 import org.apache.nifi.py4j.client.NiFiPythonGateway;
 import org.apache.nifi.py4j.client.StandardPythonClient;
@@ -36,6 +40,7 @@ import javax.net.ServerSocketFactory;
 import javax.net.SocketFactory;
 import java.io.File;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.security.SecureRandom;
 import java.util.ArrayList;
@@ -52,6 +57,8 @@ public class PythonProcess {
     private static final Logger logger = 
LoggerFactory.getLogger(PythonProcess.class);
     private static final String PYTHON_CONTROLLER_FILENAME = "Controller.py";
 
+    private static final String LOG_READER_THREAD_NAME_FORMAT = 
"python-log-%d";
+
     private final PythonProcessConfig processConfig;
     private final ControllerServiceTypeLookup controllerServiceTypeLookup;
     private final File virtualEnvHome;
@@ -66,6 +73,8 @@ public class PythonProcess {
     private volatile boolean shutdown = false;
     private volatile List<String> extensionDirs;
     private volatile String workDir;
+    private Thread logReaderThread;
+    private String logListenerId;
 
 
     public PythonProcess(final PythonProcessConfig processConfig, final 
ControllerServiceTypeLookup controllerServiceTypeLookup, final File 
virtualEnvHome,
@@ -118,6 +127,10 @@ public class PythonProcess {
         this.process = launchPythonProcess(listeningPort, authToken);
         this.process.onExit().thenAccept(this::handlePythonProcessDied);
 
+        final String logReaderThreadName = 
LOG_READER_THREAD_NAME_FORMAT.formatted(process.pid());
+        final Runnable logReaderCommand = new 
PythonProcessLogReader(process.inputReader(StandardCharsets.UTF_8));
+        this.logReaderThread = 
Thread.ofVirtual().name(logReaderThreadName).start(logReaderCommand);
+
         final StandardPythonClient pythonClient = new 
StandardPythonClient(gateway);
         controller = pythonClient.getController();
 
@@ -151,6 +164,9 @@ public class PythonProcess {
             throw new RuntimeException("Failed to start Python Bridge", 
lastException);
         }
 
+        logListenerId = Long.toString(process.pid());
+        StandardLogLevelChangeHandler.getHandler().addListener(logListenerId, 
new PythonProcessLogLevelChangeListener());
+
         controller.setControllerServiceTypeLookup(controllerServiceTypeLookup);
         logger.info("Successfully started and pinged Python Server. Python 
Process = {}", process);
     }
@@ -211,7 +227,6 @@ public class PythonProcess {
     private Process launchPythonProcess(final int listeningPort, final String 
authToken) throws IOException {
         final File pythonFrameworkDirectory = 
processConfig.getPythonFrameworkDirectory();
         final File pythonApiDirectory = new 
File(pythonFrameworkDirectory.getParentFile(), "api");
-        final File pythonLogsDirectory = 
processConfig.getPythonLogsDirectory();
         final File pythonCmdFile = new File(processConfig.getPythonCommand());
         final String pythonCmd = pythonCmdFile.getName();
         final File pythonCommandFile = new File(virtualEnvHome, "bin/" + 
pythonCmd);
@@ -225,14 +240,12 @@ public class PythonProcess {
 
         String pythonPath = pythonApiDirectory.getAbsolutePath();
 
-
         if (processConfig.isDebugController() && 
"Controller".equals(componentId)) {
             commands.add("-m");
             commands.add("debugpy");
             commands.add("--listen");
             commands.add(processConfig.getDebugHost() + ":" + 
processConfig.getDebugPort());
-            commands.add("--log-to");
-            
commands.add(processConfig.getDebugLogsDirectory().getAbsolutePath());
+            commands.add("--log-to-stderr");
 
             pythonPath = pythonPath + File.pathSeparator + 
virtualEnvHome.getAbsolutePath();
         }
@@ -241,12 +254,13 @@ public class PythonProcess {
         processBuilder.command(commands);
 
         processBuilder.environment().put("JAVA_PORT", 
String.valueOf(listeningPort));
-        processBuilder.environment().put("LOGS_DIR", 
pythonLogsDirectory.getAbsolutePath());
         processBuilder.environment().put("ENV_HOME", 
virtualEnvHome.getAbsolutePath());
         processBuilder.environment().put("PYTHONPATH", pythonPath);
         processBuilder.environment().put("PYTHON_CMD", 
pythonCommandFile.getAbsolutePath());
         processBuilder.environment().put("AUTH_TOKEN", authToken);
-        processBuilder.inheritIO();
+
+        // Redirect error stream to standard output stream
+        processBuilder.redirectErrorStream(true);
 
         logger.info("Launching Python Process {} {} with working directory {} 
to communicate with Java on Port {}",
             pythonCommand, controllerPyFile.getAbsolutePath(), virtualEnvHome, 
listeningPort);
@@ -329,6 +343,8 @@ public class PythonProcess {
     }
 
     private synchronized void killProcess() {
+        
StandardLogLevelChangeHandler.getHandler().removeListener(logListenerId);
+
         if (server != null) {
             try {
                 server.shutdown();
@@ -358,6 +374,10 @@ public class PythonProcess {
 
             process = null;
         }
+
+        if (logReaderThread != null) {
+            logReaderThread.interrupt();
+        }
     }
 
     public void discoverExtensions(final List<String> directories, final 
String workDirectory) {
@@ -433,4 +453,18 @@ public class PythonProcess {
     }
 
     private record CreatedProcessor(String identifier, String type, 
PythonProcessorBridge processorBridge) {}
+
+    private class PythonProcessLogLevelChangeListener implements 
LogLevelChangeListener {
+        /**
+         * Publish log level changes to Python Controller with conversion from 
framework log level to Python log level
+         *
+         * @param loggerName Name of logger with updated level
+         * @param logLevel New log level
+         */
+        @Override
+        public void onLevelChange(final String loggerName, final LogLevel 
logLevel) {
+            final PythonLogLevel pythonLogLevel = 
PythonLogLevel.valueOf(logLevel);
+            controller.setLoggerLevel(loggerName, pythonLogLevel.getLevel());
+        }
+    }
 }
\ No newline at end of file
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/PythonProcessLogReader.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/PythonProcessLogReader.java
new file mode 100644
index 0000000000..4a52bc35db
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/PythonProcessLogReader.java
@@ -0,0 +1,176 @@
+/*
+ * 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.nifi.py4j;
+
+import org.apache.nifi.py4j.logging.PythonLogLevel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.stream.Collectors;
+
+/**
+ * Runnable Command for reading a line from Process Output Stream and writing 
to a Logger
+ */
+class PythonProcessLogReader implements Runnable {
+    private static final int LOG_LEVEL_BEGIN_INDEX = 0;
+
+    private static final int LOG_LEVEL_END_INDEX = 2;
+
+    private static final int MESSAGE_BEGIN_INDEX = 3;
+
+    private static final char NAME_MESSAGE_SEPARATOR = ':';
+
+    private static final int MINIMUM_LOGGER_NAME_INDEX = 3;
+
+    private static final String LOG_PREFIX = "PY4JLOG";
+
+    private static final int PREFIXED_LOG_LEVEL_BEGIN_INDEX = 8;
+
+    private static final String LINE_SEPARATOR = System.lineSeparator();
+
+    private static final Map<String, PythonLogLevel> PYTHON_LOG_LEVELS = 
Arrays.stream(PythonLogLevel.values()).collect(
+            Collectors.toUnmodifiableMap(
+                    pythonLogLevel -> 
Integer.toString(pythonLogLevel.getLevel()),
+                    pythonLogLevel -> pythonLogLevel
+            )
+    );
+
+    private final Logger processLogger = 
LoggerFactory.getLogger("org.apache.nifi.py4j.ProcessLog");
+
+    private final BufferedReader processReader;
+
+    /**
+     * Standard constructor with Buffered Reader connected to Python Process 
Output Stream
+     *
+     * @param processReader Reader from Process Output Stream
+     */
+    PythonProcessLogReader(final BufferedReader processReader) {
+        this.processReader = Objects.requireNonNull(processReader, "Reader 
required");
+    }
+
+    /**
+     * Read lines from Process Reader and write log messages based on parsed 
level and named logger
+     */
+    @Override
+    public void run() {
+        final Queue<ParsedRecord> parsedRecords = new ArrayDeque<>();
+
+        try {
+            String line = processReader.readLine();
+            while (line != null) {
+                try {
+                    processLine(line, parsedRecords);
+
+                    if (parsedRecords.size() == 2) {
+                        // Log previous record after creating a new record
+                        final ParsedRecord parsedRecord = 
parsedRecords.remove();
+                        log(parsedRecord);
+                    }
+
+                    if (!processReader.ready()) {
+                        // Log queued records when Process Reader is not ready
+                        while (!parsedRecords.isEmpty()) {
+                            final ParsedRecord parsedRecord = 
parsedRecords.poll();
+                            log(parsedRecord);
+                        }
+                    }
+                } catch (final Exception e) {
+                    processLogger.error("Failed to handle log from Python 
Process", e);
+                }
+
+                // Read and block for subsequent lines
+                line = processReader.readLine();
+            }
+        } catch (final IOException e) {
+            processLogger.error("Failed to read output of Python Process", e);
+        }
+
+        // Handle last buffered message following closure of process stream
+        for (final ParsedRecord parsedRecord : parsedRecords) {
+            log(parsedRecord);
+        }
+    }
+
+    private void processLine(final String line, final Queue<ParsedRecord> 
parsedRecords) {
+        final int logPrefixIndex = line.indexOf(LOG_PREFIX);
+        if (logPrefixIndex == 0) {
+            final String levelLogLine = 
line.substring(PREFIXED_LOG_LEVEL_BEGIN_INDEX);
+            final String levelNumber = 
levelLogLine.substring(LOG_LEVEL_BEGIN_INDEX, LOG_LEVEL_END_INDEX);
+            final PythonLogLevel logLevel = 
PYTHON_LOG_LEVELS.getOrDefault(levelNumber, PythonLogLevel.NOTSET);
+            final String loggerMessage = 
levelLogLine.substring(MESSAGE_BEGIN_INDEX);
+
+            final int nameSeparatorIndex = 
loggerMessage.indexOf(NAME_MESSAGE_SEPARATOR);
+            final String message;
+            final Logger logger;
+            if (nameSeparatorIndex < MINIMUM_LOGGER_NAME_INDEX) {
+                // Set ProcessLog when named logger not found
+                logger = processLogger;
+                message = loggerMessage;
+            } else {
+                final String loggerName = loggerMessage.substring(0, 
nameSeparatorIndex);
+                logger = LoggerFactory.getLogger(loggerName);
+
+                final int messageBeginIndex = nameSeparatorIndex + 1;
+                message = loggerMessage.substring(messageBeginIndex);
+            }
+
+            final StringBuilder buffer = new StringBuilder(message);
+            final ParsedRecord parsedRecord = new ParsedRecord(logLevel, 
logger, buffer);
+            parsedRecords.add(parsedRecord);
+        } else {
+            final ParsedRecord lastRecord = parsedRecords.peek();
+            if (lastRecord == null) {
+                final StringBuilder buffer = new StringBuilder(line);
+                final ParsedRecord parsedRecord = new 
ParsedRecord(PythonLogLevel.INFO, processLogger, buffer);
+                parsedRecords.add(parsedRecord);
+            } else if (!line.isEmpty()) {
+                // Add line separator for buffering multiple lines in the same 
record
+                lastRecord.buffer.append(LINE_SEPARATOR);
+                lastRecord.buffer.append(line);
+            }
+        }
+    }
+
+    private void log(final ParsedRecord parsedRecord) {
+        final PythonLogLevel logLevel = parsedRecord.level;
+        final Logger logger = parsedRecord.logger;
+        final String message = parsedRecord.buffer.toString();
+
+        if (PythonLogLevel.DEBUG == logLevel) {
+            logger.debug(message);
+        } else if (PythonLogLevel.INFO == logLevel) {
+            logger.info(message);
+        } else if (PythonLogLevel.WARNING == logLevel) {
+            logger.warn(message);
+        } else if (PythonLogLevel.ERROR == logLevel) {
+            logger.error(message);
+        } else if (PythonLogLevel.CRITICAL == logLevel) {
+            logger.error(message);
+        } else {
+            logger.warn(message);
+        }
+    }
+
+    private record ParsedRecord(PythonLogLevel level, Logger logger, 
StringBuilder buffer) {}
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/StandardPythonBridge.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/StandardPythonBridge.java
index bb3cbe496a..cc2a5d8489 100644
--- 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/StandardPythonBridge.java
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/StandardPythonBridge.java
@@ -18,6 +18,9 @@
 package org.apache.nifi.py4j;
 
 import org.apache.nifi.components.AsyncLoadedProcessor;
+import org.apache.nifi.py4j.logback.LevelChangeListener;
+import org.apache.nifi.py4j.logging.LogLevelChangeHandler;
+import org.apache.nifi.py4j.logging.StandardLogLevelChangeHandler;
 import org.apache.nifi.python.BoundObjectCounts;
 import org.apache.nifi.python.ControllerServiceTypeLookup;
 import org.apache.nifi.python.PythonBridge;
@@ -71,6 +74,9 @@ public class StandardPythonBridge implements PythonBridge {
         logger.debug("{} launching Python Process", this);
 
         try {
+            final LogLevelChangeHandler logLevelChangeHandler = 
StandardLogLevelChangeHandler.getHandler();
+            LevelChangeListener.registerLogbackListener(logLevelChangeHandler);
+
             final File envHome = new 
File(processConfig.getPythonWorkingDirectory(), "controller");
             controllerProcess = new PythonProcess(processConfig, 
serviceTypeLookup, envHome, "Controller", "Controller");
             controllerProcess.start();
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logback/LevelChangeListener.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logback/LevelChangeListener.java
new file mode 100644
index 0000000000..8efc49b36c
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logback/LevelChangeListener.java
@@ -0,0 +1,145 @@
+/*
+ * 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.nifi.py4j.logback;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.LoggerContextListener;
+import org.apache.nifi.py4j.logging.LogLevelChangeListener;
+import org.apache.nifi.logging.LogLevel;
+import org.slf4j.ILoggerFactory;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Logback Listener implementation for tracking Logger Level changes and 
notifying framework listeners
+ */
+public class LevelChangeListener implements LoggerContextListener {
+    private static final Map<Level, LogLevel> LEVELS = Map.of(
+            Level.ALL, LogLevel.TRACE,
+            Level.TRACE, LogLevel.TRACE,
+            Level.DEBUG, LogLevel.DEBUG,
+            Level.INFO, LogLevel.INFO,
+            Level.WARN, LogLevel.WARN,
+            Level.ERROR, LogLevel.ERROR,
+            Level.OFF, LogLevel.NONE
+    );
+
+    private static final List<Pattern> EXCLUDED_LOGGERS = List.of(
+            Pattern.compile("^$"),
+            Pattern.compile("^org|com|net$"),
+            Pattern.compile("^jetbrains.*"),
+            Pattern.compile("^org\\.apache.*"),
+            Pattern.compile("^org\\.eclipse.*"),
+            Pattern.compile("^org\\.glassfish.*"),
+            Pattern.compile("^org\\.springframework.*"),
+            Pattern.compile("^org\\.opensaml.*"),
+            Pattern.compile("^software\\.amazon.*")
+    );
+
+    private final LogLevelChangeListener logLevelChangeListener;
+
+    LevelChangeListener(final LogLevelChangeListener logLevelChangeListener) {
+        this.logLevelChangeListener = 
Objects.requireNonNull(logLevelChangeListener, "Listener required");
+    }
+
+    /**
+     * Register an instance of Logback LevelChangeListener routing to 
framework LogLevelChangeListener
+     */
+    public static void registerLogbackListener(final LogLevelChangeListener 
logLevelChangeListener) {
+        final LevelChangeListener levelChangeListener = new 
LevelChangeListener(logLevelChangeListener);
+        final ILoggerFactory loggerFactory = LoggerFactory.getILoggerFactory();
+        if (loggerFactory instanceof LoggerContext loggerContext) {
+            loggerContext.addListener(levelChangeListener);
+            levelChangeListener.onStart(loggerContext);
+        }
+    }
+
+    /**
+     * Reset Resistant enabled to support persistent behavior when the 
LoggerContext is changed
+     *
+     * @return Reset Resistant enabled
+     */
+    @Override
+    public boolean isResetResistant() {
+        return true;
+    }
+
+    /**
+     * Set initial Logger Levels from registered loggers when starting
+     *
+     * @param loggerContext Logback Context
+     */
+    @Override
+    public void onStart(final LoggerContext loggerContext) {
+        for (final Logger logger : loggerContext.getLoggerList()) {
+            onLevelChange(logger, logger.getEffectiveLevel());
+        }
+    }
+
+    /**
+     * Set Logger Levels from registered loggers when resetting
+     *
+     * @param loggerContext Logback Context
+     */
+    @Override
+    public void onReset(final LoggerContext loggerContext) {
+        onStart(loggerContext);
+    }
+
+    @Override
+    public void onStop(final LoggerContext loggerContext) {
+
+    }
+
+    /**
+     * Send logger level changes to framework handler for subsequent 
notification
+     *
+     * @param logger Logback Logger with new level
+     * @param level New level assigned for Logback Logger
+     */
+    @Override
+    public void onLevelChange(final Logger logger, final Level level) {
+        Objects.requireNonNull(level, "Level required");
+
+        final String loggerName = logger.getName();
+        if (isLoggerSupported(loggerName)) {
+            final LogLevel logLevel = LEVELS.getOrDefault(level, 
LogLevel.WARN);
+            logLevelChangeListener.onLevelChange(loggerName, logLevel);
+        }
+    }
+
+    private boolean isLoggerSupported(final String loggerName) {
+        boolean supported = true;
+
+        for (final Pattern loggerPattern : EXCLUDED_LOGGERS) {
+            final Matcher matcher = loggerPattern.matcher(loggerName);
+            if (matcher.matches()) {
+                supported = false;
+                break;
+            }
+        }
+
+        return supported;
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/LogLevelChangeHandler.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/LogLevelChangeHandler.java
new file mode 100644
index 0000000000..3cf50a99c0
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/LogLevelChangeHandler.java
@@ -0,0 +1,37 @@
+/*
+ * 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.nifi.py4j.logging;
+
+/**
+ * Handler abstraction for registering Listeners and invoking Listeners on 
level changes
+ */
+public interface LogLevelChangeHandler extends LogLevelChangeListener {
+    /**
+     * Register Log Level Change Listener with provided identifier
+     *
+     * @param identifier Tracking identifier associated with Log Level Change 
Listener
+     * @param listener Log Level Change Listener to be registered
+     */
+    void addListener(String identifier, LogLevelChangeListener listener);
+
+    /**
+     * Remove registered listener based on provided identifier
+     *
+     * @param identifier Tracking identifier of registered listener to be 
removed
+     */
+    void removeListener(String identifier);
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/LogLevelChangeListener.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/LogLevelChangeListener.java
new file mode 100644
index 0000000000..d9b2077f4d
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/LogLevelChangeListener.java
@@ -0,0 +1,32 @@
+/*
+ * 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.nifi.py4j.logging;
+
+import org.apache.nifi.logging.LogLevel;
+
+/**
+ * Listener abstraction for subscribing to notification of level changes for 
named loggers
+ */
+public interface LogLevelChangeListener {
+    /**
+     * Handle level change notification for named logger
+     *
+     * @param loggerName Name of logger with updated level
+     * @param logLevel New log level
+     */
+    void onLevelChange(String loggerName, LogLevel logLevel);
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/PythonLogLevel.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/PythonLogLevel.java
new file mode 100644
index 0000000000..f32e5245c5
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/PythonLogLevel.java
@@ -0,0 +1,67 @@
+/*
+ * 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.nifi.py4j.logging;
+
+import org.apache.nifi.logging.LogLevel;
+
+import java.util.Map;
+
+/**
+ * Python Log Level enumeration with level numbers according to Python logging 
module documentation
+ */
+public enum PythonLogLevel {
+    NOTSET(0),
+
+    DEBUG(10),
+
+    INFO(20),
+
+    WARNING(30),
+
+    ERROR(40),
+
+    CRITICAL(50);
+
+    private static final Map<LogLevel, PythonLogLevel> FRAMEWORK_LEVELS = 
Map.of(
+            LogLevel.NONE, NOTSET,
+            LogLevel.DEBUG, DEBUG,
+            LogLevel.INFO, INFO,
+            LogLevel.WARN, WARNING,
+            LogLevel.ERROR, ERROR,
+            LogLevel.FATAL, CRITICAL
+    );
+
+    private final int level;
+
+    PythonLogLevel(final int level) {
+        this.level = level;
+    }
+
+    public int getLevel() {
+        return level;
+    }
+
+    /**
+     * Get Python Log Level equivalent from framework Log Level
+     *
+     * @param logLevel Framework Log Level
+     * @return Python Log Level
+     */
+    public static PythonLogLevel valueOf(LogLevel logLevel) {
+        return FRAMEWORK_LEVELS.getOrDefault(logLevel, WARNING);
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/StandardLogLevelChangeHandler.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/StandardLogLevelChangeHandler.java
new file mode 100644
index 0000000000..150798c317
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/main/java/org/apache/nifi/py4j/logging/StandardLogLevelChangeHandler.java
@@ -0,0 +1,100 @@
+/*
+ * 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.nifi.py4j.logging;
+
+import org.apache.nifi.logging.LogLevel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Standard implementation of Log Level Change Handler with singleton instance 
for shared collection of listeners
+ */
+public class StandardLogLevelChangeHandler implements LogLevelChangeHandler {
+    private static final StandardLogLevelChangeHandler HANDLER = new 
StandardLogLevelChangeHandler();
+
+    private static final Logger handlerLogger = 
LoggerFactory.getLogger(StandardLogLevelChangeHandler.class);
+
+    private final Map<String, LogLevelChangeListener> listeners = new 
ConcurrentHashMap<>();
+
+    private final Map<String, LogLevel> loggerLevels = new 
ConcurrentHashMap<>();
+
+    private StandardLogLevelChangeHandler() {
+
+    }
+
+    /**
+     * Get shared reference to Handler for Listener registration and 
centralized notification
+     *
+     * @return Shared Log Level Change Handler
+     */
+    public static LogLevelChangeHandler getHandler() {
+        return HANDLER;
+    }
+
+    /**
+     * Register Log Level Change Listener with provided identifier
+     *
+     * @param identifier Tracking identifier associated with Log Level Change 
Listener
+     * @param listener Log Level Change Listener to be registered
+     */
+    @Override
+    public void addListener(final String identifier, final 
LogLevelChangeListener listener) {
+        Objects.requireNonNull(identifier, "Identifier required");
+        Objects.requireNonNull(listener, "Listener required");
+
+        for (final Map.Entry<String, LogLevel> loggerLevel : 
loggerLevels.entrySet()) {
+            listener.onLevelChange(loggerLevel.getKey(), 
loggerLevel.getValue());
+        }
+
+        listeners.put(identifier, listener);
+        handlerLogger.trace("Added Listener [{}]", identifier);
+    }
+
+    /**
+     * Remove registered listener based on provided identifier
+     *
+     * @param identifier Tracking identifier of registered listener to be 
removed
+     */
+    @Override
+    public void removeListener(String identifier) {
+        Objects.requireNonNull(identifier, "Identifier required");
+        listeners.remove(identifier);
+        handlerLogger.trace("Removed Listener [{}]", identifier);
+    }
+
+    /**
+     * Handle level change notification for named logger and broadcast to 
registered Listeners
+     *
+     * @param loggerName Name of logger with updated level
+     * @param logLevel New log level
+     */
+    @Override
+    public void onLevelChange(final String loggerName, final LogLevel 
logLevel) {
+        Objects.requireNonNull(loggerName, "Logger Name required");
+        Objects.requireNonNull(logLevel, "Log Level required");
+        handlerLogger.trace("Logger [{}] Level [{}] changed", loggerName, 
logLevel);
+
+        loggerLevels.put(loggerName, logLevel);
+        for (final LogLevelChangeListener listener : listeners.values()) {
+            listener.onLevelChange(loggerName, logLevel);
+        }
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/test/java/org/apache/nifi/py4j/PythonProcessLogReaderTest.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/test/java/org/apache/nifi/py4j/PythonProcessLogReaderTest.java
new file mode 100644
index 0000000000..2db386df9a
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/test/java/org/apache/nifi/py4j/PythonProcessLogReaderTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.nifi.py4j;
+
+import org.apache.nifi.py4j.logging.PythonLogLevel;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PythonProcessLogReaderTest {
+
+    private static final String PROCESS_LOGGER = 
"org.apache.nifi.py4j.ProcessLog";
+
+    private static final String CONTROLLER_LOGGER = 
"org.apache.nifi.py4j.Controller";
+
+    private static final String LOG_MESSAGE = "Testing Python Processing";
+
+    private static final String LINE_FORMAT = "PY4JLOG %s %s:%s %d%n";
+
+    private static final String LINE_UNFORMATTED = "Testing message without 
level or logger";
+
+    private static final String LINE_MISSING_SEPARATOR = "%s 
%s".formatted(PythonLogLevel.INFO.getLevel(), LOG_MESSAGE);
+
+    private static final String LINE_EMPTY = "";
+
+    private static final String MESSAGE_TRACEBACK = String.format("Command 
Failed%nTrackback (most recent call last):%n  File: command.py, line 1%nError: 
name is not defined");
+
+    private static final int FIRST_LOG = 1;
+
+    private static final String FIRST_LOG_MESSAGE = String.format("%s %d", 
LOG_MESSAGE, FIRST_LOG);
+
+    private static final String NUMBERED_LOG_FORMAT = "%s %d";
+
+    @Mock
+    private Logger processLogger;
+
+    @Mock
+    private Logger controllerLogger;
+
+    @Test
+    void testDebug() {
+        runCommand(PythonLogLevel.DEBUG, controllerLogger);
+
+        verify(controllerLogger).debug(eq(FIRST_LOG_MESSAGE));
+    }
+
+    @Test
+    void testInfo() {
+        runCommand(PythonLogLevel.INFO, controllerLogger);
+
+        verify(controllerLogger).info(eq(FIRST_LOG_MESSAGE));
+    }
+
+    @Test
+    void testWarning() {
+        runCommand(PythonLogLevel.WARNING, controllerLogger);
+
+        verify(controllerLogger).warn(eq(FIRST_LOG_MESSAGE));
+    }
+
+    @Test
+    void testError() {
+        runCommand(PythonLogLevel.ERROR, controllerLogger);
+
+        verify(controllerLogger).error(eq(FIRST_LOG_MESSAGE));
+    }
+
+    @Test
+    void testCritical() {
+        runCommand(PythonLogLevel.CRITICAL, controllerLogger);
+
+        verify(controllerLogger).error(eq(FIRST_LOG_MESSAGE));
+    }
+
+    @Test
+    void testUnformatted() {
+        try (MockedStatic<LoggerFactory> loggerFactory = 
mockStatic(LoggerFactory.class)) {
+            setupLogger(loggerFactory, processLogger, PROCESS_LOGGER);
+
+            final BufferedReader reader = new BufferedReader(new 
StringReader(LINE_UNFORMATTED));
+            final Runnable command = new PythonProcessLogReader(reader);
+            command.run();
+
+            verify(processLogger).info(eq(LINE_UNFORMATTED));
+        }
+    }
+
+    @Test
+    void testMissingSeparator() {
+        try (MockedStatic<LoggerFactory> loggerFactory = 
mockStatic(LoggerFactory.class)) {
+            setupLogger(loggerFactory, processLogger, PROCESS_LOGGER);
+
+            final BufferedReader reader = new BufferedReader(new 
StringReader(LINE_MISSING_SEPARATOR));
+            final Runnable command = new PythonProcessLogReader(reader);
+            command.run();
+
+            verify(processLogger).info(eq(LINE_MISSING_SEPARATOR));
+        }
+    }
+
+    @Test
+    void testEmpty() {
+        try (MockedStatic<LoggerFactory> loggerFactory = 
mockStatic(LoggerFactory.class)) {
+            setupLogger(loggerFactory, processLogger, PROCESS_LOGGER);
+
+            final BufferedReader reader = new BufferedReader(new 
StringReader(LINE_EMPTY));
+            final Runnable command = new PythonProcessLogReader(reader);
+            command.run();
+
+            verifyNoInteractions(processLogger);
+        }
+    }
+
+    @Test
+    void testInfoMultipleMessages() {
+        final int messages = 2;
+
+        try (MockedStatic<LoggerFactory> loggerFactory = 
mockStatic(LoggerFactory.class)) {
+            setupLogger(loggerFactory, controllerLogger, CONTROLLER_LOGGER);
+
+            final StringBuilder builder = new StringBuilder();
+            for (int i = 0; i < messages; i++) {
+                builder.append(getLine(PythonLogLevel.INFO, i));
+                builder.append(System.lineSeparator());
+            }
+
+            final String lines = builder.toString();
+            final BufferedReader reader = new BufferedReader(new 
StringReader(lines));
+            final Runnable command = new PythonProcessLogReader(reader);
+            command.run();
+        }
+
+        for (int i = 0; i < messages; i++) {
+            final String expected = NUMBERED_LOG_FORMAT.formatted(LOG_MESSAGE, 
i);
+            verify(controllerLogger).info(eq(expected));
+        }
+    }
+
+    @Test
+    void testErrorMultipleLines() {
+        try (MockedStatic<LoggerFactory> loggerFactory = 
mockStatic(LoggerFactory.class)) {
+            setupLogger(loggerFactory, controllerLogger, CONTROLLER_LOGGER);
+
+            final String lines = 
LINE_FORMAT.formatted(PythonLogLevel.ERROR.getLevel(), CONTROLLER_LOGGER, 
MESSAGE_TRACEBACK, FIRST_LOG);
+
+            final BufferedReader reader = new BufferedReader(new 
StringReader(lines));
+            final Runnable command = new PythonProcessLogReader(reader);
+            command.run();
+        }
+
+        final String expected = 
NUMBERED_LOG_FORMAT.formatted(MESSAGE_TRACEBACK, FIRST_LOG);
+        verify(controllerLogger).error(eq(expected));
+    }
+
+    @Test
+    void testReaderException() throws IOException {
+        try (MockedStatic<LoggerFactory> loggerFactory = 
mockStatic(LoggerFactory.class)) {
+            setupLogger(loggerFactory, processLogger, PROCESS_LOGGER);
+
+            final BufferedReader reader = mock(BufferedReader.class);
+            when(reader.readLine()).thenThrow(new IOException());
+
+            final Runnable command = new PythonProcessLogReader(reader);
+            command.run();
+
+            verify(processLogger).error(anyString(), isA(IOException.class));
+        }
+    }
+
+    private void runCommand(final PythonLogLevel logLevel, final Logger 
logger) {
+        try (MockedStatic<LoggerFactory> loggerFactory = 
mockStatic(LoggerFactory.class)) {
+            setupLogger(loggerFactory, logger, CONTROLLER_LOGGER);
+
+            final String line = getLine(logLevel, FIRST_LOG);
+            final BufferedReader reader = new BufferedReader(new 
StringReader(line));
+            final Runnable command = new PythonProcessLogReader(reader);
+            command.run();
+        }
+    }
+
+    private void setupLogger(final MockedStatic<LoggerFactory> loggerFactory, 
final Logger logger, final String loggerName) {
+        loggerFactory.when(() -> 
LoggerFactory.getLogger(eq(loggerName))).thenReturn(logger);
+    }
+
+    private String getLine(final PythonLogLevel logLevel, final int number) {
+        return LINE_FORMAT.formatted(logLevel.getLevel(), CONTROLLER_LOGGER, 
LOG_MESSAGE, number);
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/test/java/org/apache/nifi/py4j/logback/LevelChangeListenerTest.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/test/java/org/apache/nifi/py4j/logback/LevelChangeListenerTest.java
new file mode 100644
index 0000000000..6e4a4c06bb
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-bridge/src/test/java/org/apache/nifi/py4j/logback/LevelChangeListenerTest.java
@@ -0,0 +1,67 @@
+package org.apache.nifi.py4j.logback;/*
+ * 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.
+ */
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import org.apache.nifi.py4j.logging.LogLevelChangeListener;
+import org.apache.nifi.py4j.logging.StandardLogLevelChangeHandler;
+import org.apache.nifi.logging.LogLevel;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class LevelChangeListenerTest {
+
+    private static final String LOGGER_NAME = Test.class.getName();
+
+    @Mock
+    private Logger logger;
+
+    @Mock
+    private LogLevelChangeListener logLevelChangeListener;
+
+    private LevelChangeListener listener;
+
+    @BeforeEach
+    void setListener() {
+        listener = new 
LevelChangeListener(StandardLogLevelChangeHandler.getHandler());
+
+        
StandardLogLevelChangeHandler.getHandler().addListener(LogLevelChangeListener.class.getSimpleName(),
 logLevelChangeListener);
+    }
+
+    @AfterEach
+    void removeListener() {
+        
StandardLogLevelChangeHandler.getHandler().removeListener(LogLevelChangeListener.class.getSimpleName());
+    }
+
+    @Test
+    void testOnLevelChange() {
+        when(logger.getName()).thenReturn(LOGGER_NAME);
+
+        listener.onLevelChange(logger, Level.INFO);
+
+        verify(logLevelChangeListener).onLevelChange(eq(LOGGER_NAME), 
eq(LogLevel.INFO));
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-integration-tests/src/test/java/org.apache.nifi.py4j/PythonControllerInteractionIT.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-integration-tests/src/test/java/org.apache.nifi.py4j/PythonControllerInteractionIT.java
index c41d459a17..7fb0399166 100644
--- 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-integration-tests/src/test/java/org.apache.nifi.py4j/PythonControllerInteractionIT.java
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-py4j-integration-tests/src/test/java/org.apache.nifi.py4j/PythonControllerInteractionIT.java
@@ -89,7 +89,6 @@ public class PythonControllerInteractionIT {
             .commsTimeout(Duration.ofSeconds(0))
             .maxPythonProcessesPerType(25)
             .maxPythonProcesses(100)
-            .pythonLogsDirectory(logsDir)
             .build();
 
         Files.createDirectories(logsDir.toPath());
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonController.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonController.java
index c5ae45bde2..b840603b9e 100644
--- 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonController.java
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonController.java
@@ -99,4 +99,12 @@ public interface PythonController {
      * @return the details that have been discovered
      */
     PythonProcessorDetails getProcessorDetails(String type, String version);
+
+    /**
+     * Set level for specified logger name in Python logging.Logger objects
+     *
+     * @param loggerName Python logger name to be updated
+     * @param level Python log level according to Python logging module 
documentation
+     */
+    void setLoggerLevel(String loggerName, int level);
 }
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonProcessConfig.java
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonProcessConfig.java
index 72c8c85d4c..4e32b5e89d 100644
--- 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonProcessConfig.java
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework-api/src/main/java/org/apache/nifi/python/PythonProcessConfig.java
@@ -30,20 +30,17 @@ public class PythonProcessConfig {
     private final File pythonFrameworkDirectory;
     private final List<File> pythonExtensionsDirectories;
     private final File pythonWorkingDirectory;
-    private final File pythonLogsDirectory;
     private final Duration commsTimeout;
     private final int maxPythonProcesses;
     private final int maxPythonProcessesPerType;
     private final boolean debugController;
     private final String debugHost;
     private final int debugPort;
-    private final File debugLogsDirectory;
 
     private PythonProcessConfig(final Builder builder) {
         this.pythonCommand = builder.pythonCommand;
         this.pythonFrameworkDirectory = builder.pythonFrameworkDirectory;
         this.pythonExtensionsDirectories = builder.pythonExtensionsDirectories;
-        this.pythonLogsDirectory = builder.pythonLogsDirectory;
         this.pythonWorkingDirectory = builder.pythonWorkingDirectory;
         this.commsTimeout = builder.commsTimeout;
         this.maxPythonProcesses = builder.maxProcesses;
@@ -51,7 +48,6 @@ public class PythonProcessConfig {
         this.debugController = builder.debugController;
         this.debugPort = builder.debugPort;
         this.debugHost = builder.debugHost;
-        this.debugLogsDirectory = builder.debugLogsDirectory;
     }
 
     public String getPythonCommand() {
@@ -66,10 +62,6 @@ public class PythonProcessConfig {
         return pythonExtensionsDirectories;
     }
 
-    public File getPythonLogsDirectory() {
-        return pythonLogsDirectory;
-    }
-
     public File getPythonWorkingDirectory() {
         return pythonWorkingDirectory;
     }
@@ -98,15 +90,10 @@ public class PythonProcessConfig {
         return debugPort;
     }
 
-    public File getDebugLogsDirectory() {
-        return debugLogsDirectory;
-    }
-
     public static class Builder {
         private String pythonCommand = "python3";
         private File pythonFrameworkDirectory = new File("python/framework");
         private List<File> pythonExtensionsDirectories = 
Collections.singletonList(new File("python/extensions"));
-        private File pythonLogsDirectory = new File("./logs");
         private File pythonWorkingDirectory = new File("python");
         private Duration commsTimeout = Duration.ofSeconds(0);
         private int maxProcesses;
@@ -114,8 +101,6 @@ public class PythonProcessConfig {
         private boolean debugController = false;
         private String debugHost = "localhost";
         private int debugPort = 5678;
-        private File debugLogsDirectory = new File("logs/");
-
 
         public Builder pythonCommand(final String command) {
             this.pythonCommand = command;
@@ -164,11 +149,6 @@ public class PythonProcessConfig {
             return this;
         }
 
-        public Builder pythonLogsDirectory(final File logsDirectory) {
-            this.pythonLogsDirectory = logsDirectory;
-            return this;
-        }
-
         public Builder enableControllerDebug(final boolean enableDebug) {
             this.debugController = enableDebug;
             return this;
@@ -184,11 +164,6 @@ public class PythonProcessConfig {
             return this;
         }
 
-        public Builder debugLogsDirectory(final File debugLogsDirectory) {
-            this.debugLogsDirectory = debugLogsDirectory;
-            return this;
-        }
-
         public PythonProcessConfig build() {
             return new PythonProcessConfig(this);
         }
diff --git 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework/src/main/python/framework/Controller.py
 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework/src/main/python/framework/Controller.py
index 8919924c06..05e0d4f6b0 100644
--- 
a/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework/src/main/python/framework/Controller.py
+++ 
b/nifi-nar-bundles/nifi-py4j-bundle/nifi-python-framework/src/main/python/framework/Controller.py
@@ -15,6 +15,7 @@
 
 import logging
 import os
+import sys
 from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
 
 from py4j.java_gateway import JavaGateway, CallbackServerParameters, 
GatewayParameters
@@ -29,19 +30,14 @@ import ExtensionManager
 threadpool_attrs = dir(ThreadPoolExecutor)
 processpool_attrs = dir(ProcessPoolExecutor)
 
-
-# Initialize logging
-logger = logging.getLogger("org.apache.nifi.py4j.Controller")
-logger.setLevel(logging.INFO)
-
-logging.getLogger("py4j").setLevel(logging.WARN)
-
-logsDir = os.getenv('LOGS_DIR')
-logging.basicConfig(filename=logsDir + '/nifi-python.log',
-                    format='%(asctime)s %(levelname)s %(name)s %(message)s',
+# Set log format with level number and separator as expected in 
PythonProcessReaderCommand
+logging.basicConfig(stream=sys.stderr,
+                    format='PY4JLOG %(levelno)s %(name)s:%(message)s',
                     encoding='utf-8',
                     level=logging.INFO)
 
+logger = logging.getLogger("org.apache.nifi.py4j.Controller")
+
 
 class Controller:
 
@@ -92,6 +88,9 @@ class Controller:
     def setControllerServiceTypeLookup(self, typeLookup):
         self.controllerServiceTypeLookup = typeLookup
 
+    def setLoggerLevel(self, loggerName, level):
+        logging.getLogger(loggerName).setLevel(level)
+
     class Java:
         implements = ["org.apache.nifi.py4j.PythonController"]
 
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node1/nifi.properties
 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node1/nifi.properties
index ce4aa9faf1..f9ef29179d 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node1/nifi.properties
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node1/nifi.properties
@@ -48,7 +48,6 @@ 
nifi.python.extensions.source.directory.default=./python/extensions
 nifi.python.working.directory=./work/python
 nifi.python.max.processes=100
 nifi.python.max.processes.per.extension.type=10
-nifi.python.logs.directory=./logs
 
 ####################
 # State Management #
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node2/nifi.properties
 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node2/nifi.properties
index d507b92b22..8b45f32b17 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node2/nifi.properties
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/clustered/node2/nifi.properties
@@ -48,7 +48,6 @@ 
nifi.python.extensions.source.directory.default=./python/extensions
 nifi.python.working.directory=./work/python
 nifi.python.max.processes=100
 nifi.python.max.processes.per.extension.type=10
-nifi.python.logs.directory=./logs
 
 ####################
 # State Management #
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/default/nifi.properties
 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/default/nifi.properties
index 6ce80350c4..4758d714dc 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/default/nifi.properties
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/default/nifi.properties
@@ -48,7 +48,6 @@ 
nifi.python.extensions.source.directory.default=./python/extensions
 nifi.python.working.directory=./work/python
 nifi.python.max.processes=100
 nifi.python.max.processes.per.extension.type=10
-nifi.python.logs.directory=./logs
 
 ####################
 # State Management #
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/logback.xml
 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/logback.xml
index 806958ddbf..a3047818dd 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/logback.xml
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/logback.xml
@@ -115,6 +115,9 @@
     <logger name="org.apache.calcite.runtime.CalciteException" level="OFF" />
     <logger name="deprecation" level="OFF" />
 
+    <!-- Py4J set to WARN to avoid verbose socket communication messages -->
+    <logger name="py4j" level="WARN" />
+
     <logger name="org.apache.curator.framework.recipes.leader.LeaderSelector" 
level="OFF" />
     <logger name="org.apache.curator.ConnectionState" level="OFF" />
 
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/nifi.properties
 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/nifi.properties
index 0069027b3e..faf92c6a96 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/nifi.properties
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/resources/conf/pythonic/nifi.properties
@@ -52,7 +52,6 @@ 
nifi.python.extensions.source.directory.default=./python/extensions
 nifi.python.working.directory=./work/python
 nifi.python.max.processes=100
 nifi.python.max.processes.per.extension.type=10
-nifi.python.logs.directory=./logs
 
 ####################
 # State Management #

Reply via email to