Repository: apache/airflow

Issue:
CloudWatch task handler doesn't fall back to local logs when Amazon CloudWatch logs aren't found
This is really a CloudWatch handler issue - not "airflow" core.

### Discussed in https://github.com/apache/airflow/discussions/27395

<div type='discussions-op-text'>

<sup>Originally posted by **matthewblock** October 24, 2022</sup>
### Apache Airflow version

Other Airflow 2 version (please specify below)

### What happened

We recently activated AWS Cloudwatch logs. We were hoping the logs server would gracefully handle task logs that previously existed but were not written to Cloudwatch, but when fetching the remote logs failed (expected), the logs server didn't fall back to local logs.

```
*** Reading remote log from Cloudwatch log_group: <our log group> log_stream: <our log stream>
```

### What you think should happen instead

According to documentation [Logging for Tasks](https://airflow.apache.org/docs/apache-airflow/stable/logging-monitoring/logging-tasks.html#writing-logs-locally), when fetching remote logs fails, the logs server should fall back to looking for local logs:

> In the Airflow UI, remote logs take precedence over local logs when remote logging is enabled. If remote logs can not be found or accessed, local logs will be displayed.

This should be indicated by the message `*** Falling back to local log`.

If this is not the intended behavior, the documentation should be modified to reflect the intended behavior.

### How to reproduce

1. Run a test DAG without [AWS CloudWatch logging configured](https://airflow.apache.org/docs/apache-airflow-providers-amazon/stable/logging/cloud-watch-task-handlers.html)
2. Configure AWS CloudWatch remote logging and re-run a test DAG

### Operating System

Debian buster-slim

### Versions of Apache Airflow Providers

_No response_

### Deployment

Official Apache Airflow Helm Chart

### Deployment details

_No response_

### Anything else

_No response_

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
</div>


Discussion:
Seems it is CloudWatch handler issue 
I'll tackle this! Please assign me.
Done

Resolving patch:
diff --git a/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py b/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py
--- a/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py
+++ b/airflow/providers/amazon/aws/log/cloudwatch_task_handler.py
@@ -84,11 +84,21 @@ def close(self):
 
     def _read(self, task_instance, try_number, metadata=None):
         stream_name = self._render_filename(task_instance, try_number)
-        return (
-            f"*** Reading remote log from Cloudwatch log_group: {self.log_group} "
-            f"log_stream: {stream_name}.\n{self.get_cloudwatch_logs(stream_name=stream_name)}\n",
-            {"end_of_log": True},
-        )
+        try:
+            return (
+                f"*** Reading remote log from Cloudwatch log_group: {self.log_group} "
+                f"log_stream: {stream_name}.\n{self.get_cloudwatch_logs(stream_name=stream_name)}\n",
+                {"end_of_log": True},
+            )
+        except Exception as e:
+            log = (
+                f"*** Unable to read remote logs from Cloudwatch (log_group: {self.log_group}, log_stream: "
+                f"{stream_name})\n*** {str(e)}\n\n"
+            )
+            self.log.error(log)
+            local_log, metadata = super()._read(task_instance, try_number, metadata)
+            log += local_log
+            return log, metadata
 
     def get_cloudwatch_logs(self, stream_name: str) -> str:
         """
@@ -97,17 +107,12 @@ def get_cloudwatch_logs(self, stream_name: str) -> str:
         :param stream_name: name of the Cloudwatch log stream to get all logs from
         :return: string of all logs from the given log stream
         """
-        try:
-            events = self.hook.get_log_events(
-                log_group=self.log_group,
-                log_stream_name=stream_name,
-                start_from_head=True,
-            )
-            return "\n".join(self._event_to_str(event) for event in events)
-        except Exception:
-            msg = f"Could not read remote logs from log_group: {self.log_group} log_stream: {stream_name}."
-            self.log.exception(msg)
-            return msg
+        events = self.hook.get_log_events(
+            log_group=self.log_group,
+            log_stream_name=stream_name,
+            start_from_head=True,
+        )
+        return "\n".join(self._event_to_str(event) for event in events)
 
     def _event_to_str(self, event: dict) -> str:
         event_dt = datetime.utcfromtimestamp(event["timestamp"] / 1000.0)


Test patch:
diff --git a/tests/providers/amazon/aws/log/test_cloudwatch_task_handler.py b/tests/providers/amazon/aws/log/test_cloudwatch_task_handler.py
--- a/tests/providers/amazon/aws/log/test_cloudwatch_task_handler.py
+++ b/tests/providers/amazon/aws/log/test_cloudwatch_task_handler.py
@@ -154,49 +154,22 @@ def test_read(self):
             [{"end_of_log": True}],
         )
 
-    def test_read_wrong_log_stream(self):
-        generate_log_events(
-            self.conn,
-            self.remote_log_group,
-            "alternate_log_stream",
-            [
-                {"timestamp": 10000, "message": "First"},
-                {"timestamp": 20000, "message": "Second"},
-                {"timestamp": 30000, "message": "Third"},
-            ],
-        )
-
-        msg_template = "*** Reading remote log from Cloudwatch log_group: {} log_stream: {}.\n{}\n"
-        error_msg = (
-            "Could not read remote logs from log_group: "
-            f"{self.remote_log_group} log_stream: {self.remote_log_stream}."
-        )
-        assert self.cloudwatch_task_handler.read(self.ti) == (
-            [[("", msg_template.format(self.remote_log_group, self.remote_log_stream, error_msg))]],
-            [{"end_of_log": True}],
-        )
-
-    def test_read_wrong_log_group(self):
-        generate_log_events(
-            self.conn,
-            "alternate_log_group",
-            self.remote_log_stream,
-            [
-                {"timestamp": 10000, "message": "First"},
-                {"timestamp": 20000, "message": "Second"},
-                {"timestamp": 30000, "message": "Third"},
-            ],
-        )
-
-        msg_template = "*** Reading remote log from Cloudwatch log_group: {} log_stream: {}.\n{}\n"
-        error_msg = (
-            f"Could not read remote logs from log_group: "
-            f"{self.remote_log_group} log_stream: {self.remote_log_stream}."
-        )
-        assert self.cloudwatch_task_handler.read(self.ti) == (
-            [[("", msg_template.format(self.remote_log_group, self.remote_log_stream, error_msg))]],
-            [{"end_of_log": True}],
+    def test_should_read_from_local_on_failure_to_fetch_remote_logs(self):
+        """Check that local logs are displayed on failure to fetch remote logs"""
+        self.cloudwatch_task_handler.set_context(self.ti)
+        with mock.patch.object(self.cloudwatch_task_handler, "get_cloudwatch_logs") as mock_get_logs:
+            mock_get_logs.side_effect = Exception("Failed to connect")
+            log, metadata = self.cloudwatch_task_handler._read(self.ti, self.ti.try_number)
+        expected_log = (
+            f"*** Unable to read remote logs from Cloudwatch (log_group: {self.remote_log_group}, "
+            f"log_stream: {self.remote_log_stream})\n*** Failed to connect\n\n"
+            # The value of "log_pos" is equal to the length of this next line
+            f"*** Reading local file: {self.local_log_location}/{self.remote_log_stream}\n"
         )
+        assert log == expected_log
+        expected_log_pos = 26 + len(self.local_log_location) + len(self.remote_log_stream)
+        assert metadata == {"end_of_log": False, "log_pos": expected_log_pos}
+        mock_get_logs.assert_called_once_with(stream_name=self.remote_log_stream)
 
     def test_close_prevents_duplicate_calls(self):
         with mock.patch("watchtower.CloudWatchLogHandler.close") as mock_log_handler_close: