Repository: PrefectHQ/prefect

Issue:
2.16.1 log filter prevents logging lists
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

Prior to 2.16.1 you could log lists and it appears the new logging filter feature added in #12072 now prevents that.

### Reproduction

```python3
from prefect import flow, get_run_logger


@flow()
def test_log_list():

    logger = get_run_logger()
    lst_msg = ["hi", "I'm", "a", "list"]
    logger.info(lst_msg)


if __name__ == "__main__":
    test_log_list()
```


### Error

_No response_

### Versions

```Text
Version:             2.16.1
API version:         0.8.4
Python version:      3.10.9
Git commit:          2e3343b5
Built:               Thu, Feb 29, 2024 4:44 PM
```


### Additional context

Works fine on 2.14.10


Discussion:
I just bumped into something that likely is related. If I attempt to log a dict, Prefect crashes. This used to work in previous versions.

```Encountered exception during execution:
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/prefect/engine.py", line 863, in orchestrate_flow_run
    result = await flow_call.aresult()
             ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/prefect/_internal/concurrency/calls.py", line 326, in aresult
    return await asyncio.wrap_future(self.future)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/prefect/_internal/concurrency/calls.py", line 351, in _run_sync
    result = self.fn(*self.args, **self.kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/prefect/orchestration/flows/load_gold.py", line 51, in default
    logger.info(dbt_cloud_logs)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1849, in info
    self.log(INFO, msg, *args, **kwargs)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1887, in log
    self.logger.log(level, msg, *args, **kwargs)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1559, in log
    self._log(level, msg, args, **kwargs)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1634, in _log
    self.handle(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1643, in handle
    if (not self.disabled) and self.filter(record):
                               ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/logging/__init__.py", line 830, in filter
    result = f.filter(record)
             ^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/prefect/logging/filters.py", line 16, in filter
    record.msg = record.msg.replace(
                 ^^^^^^^^^^^^^^^^^^
AttributeError: 'dict' object has no attribute 'replace'```
Got this issue logging a requests Response objects. doesn't allow logging anything that isn't a string.

Resolving patch:
diff --git a/src/prefect/logging/filters.py b/src/prefect/logging/filters.py
--- a/src/prefect/logging/filters.py
+++ b/src/prefect/logging/filters.py
@@ -1,8 +1,31 @@
 import logging
+from typing import Any
 
+from prefect.utilities.collections import visit_collection
 from prefect.utilities.names import obfuscate
 
 
+def redact_substr(obj: Any, substr: str):
+    """
+    Redact a string from a potentially nested object.
+
+    Args:
+        obj: The object to redact the string from
+        substr: The string to redact.
+
+    Returns:
+        Any: The object with the API key redacted.
+    """
+
+    def redact_item(item):
+        if isinstance(item, str):
+            return item.replace(substr, obfuscate(substr))
+        return item
+
+    redacted_obj = visit_collection(obj, visit_fn=redact_item, return_data=True)
+    return redacted_obj
+
+
 class ObfuscateApiKeyFilter(logging.Filter):
     """
     A logging filter that obfuscates any string that matches the obfuscate_string function.
@@ -13,7 +36,6 @@ def filter(self, record: logging.LogRecord) -> bool:
         from prefect.settings import PREFECT_API_KEY
 
         if PREFECT_API_KEY:
-            record.msg = record.msg.replace(
-                PREFECT_API_KEY.value(), obfuscate(PREFECT_API_KEY.value())
-            )
+            record.msg = redact_substr(record.msg, PREFECT_API_KEY.value())
+
         return True


Test patch:
diff --git a/tests/test_logging.py b/tests/test_logging.py
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -1313,6 +1313,52 @@ def test_flow():
         assert test_api_key not in caplog.text
         assert obfuscate(test_api_key) in caplog.text
 
+    @pytest.mark.parametrize(
+        "raw_log_record,expected_log_record",
+        [
+            (
+                ["super-mega-admin-key", "in", "a", "list"],
+                ["********", "in", "a", "list"],
+            ),
+            (
+                {"super-mega-admin-key": "in", "a": "dict"},
+                {"********": "in", "a": "dict"},
+            ),
+            (
+                {
+                    "key1": "some_value",
+                    "key2": [
+                        {"nested_key": "api_key: super-mega-admin-key"},
+                        "another_value",
+                    ],
+                },
+                {
+                    "key1": "some_value",
+                    "key2": [
+                        {"nested_key": "api_key: ********"},
+                        "another_value",
+                    ],
+                },
+            ),
+        ],
+    )
+    def test_redact_substr_from_collections(
+        self, caplog, raw_log_record, expected_log_record
+    ):
+        """
+        This is a regression test for https://github.com/PrefectHQ/prefect/issues/12139
+        """
+
+        @flow()
+        def test_log_list():
+            logger = get_run_logger()
+            logger.info(raw_log_record)
+
+        with temporary_settings({PREFECT_API_KEY: "super-mega-admin-key"}):
+            test_log_list()
+
+        assert str(expected_log_record) in caplog.text
+
 
 def test_log_in_flow(caplog):
     msg = "Hello world!"