Repository: PrefectHQ/prefect

Issue:
Get flow's logs using command line
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar request and didn't find it.
- [X] I searched the Prefect documentation for this feature.

### Prefect Version

2.x

### Describe the current behavior

Based on the below conversation,
https://prefect-community.slack.com/archives/CL09KU1K7/p1670480537109379

It would be good if we get flow logs with command line

### Describe the proposed behavior

 In prefect1 we can use the `prefect get` command to get logs. expecting similar feature in prefect2 as well.

### Example Use

_No response_

### Additional context

_No response_


Discussion:
I think `prefect flow-run logs <flow_run_id> [--head [numlines=50],--tail [numlines=50], --stream]` makes sense to me!

A display of all of the logs with a handling of pagination is enough for a first PR though!

Resolving patch:
diff --git a/src/prefect/cli/flow_run.py b/src/prefect/cli/flow_run.py
--- a/src/prefect/cli/flow_run.py
+++ b/src/prefect/cli/flow_run.py
@@ -1,6 +1,7 @@
 """
 Command line interface for working with flow runs
 """
+import logging
 from typing import List
 from uuid import UUID
 
@@ -14,9 +15,9 @@
 from prefect.cli._types import PrefectTyper
 from prefect.cli._utilities import exit_with_error, exit_with_success
 from prefect.cli.root import app
-from prefect.client import get_client
+from prefect.client.orion import get_client
 from prefect.exceptions import ObjectNotFound
-from prefect.orion.schemas.filters import FlowFilter, FlowRunFilter
+from prefect.orion.schemas.filters import FlowFilter, FlowRunFilter, LogFilter
 from prefect.orion.schemas.responses import SetStateStatus
 from prefect.orion.schemas.sorting import FlowRunSort
 from prefect.orion.schemas.states import StateType
@@ -135,3 +136,41 @@ async def cancel(id: UUID):
         )
 
     exit_with_success(f"Flow run '{id}' was succcessfully scheduled for cancellation.")
+
+
+@flow_run_app.command()
+async def logs(id: UUID):
+    """
+    View logs for a flow run.
+    """
+    page_size = 200
+    offset = 0
+    more_logs = True
+    log_filter = LogFilter(flow_run_id={"any_": [id]})
+
+    async with get_client() as client:
+        # Get the flow run
+        try:
+            flow_run = await client.read_flow_run(id)
+        except ObjectNotFound as exc:
+            exit_with_error(f"Flow run {str(id)!r} not found!")
+
+        while more_logs:
+            # Get the next page of logs
+            page_logs = await client.read_logs(
+                log_filter=log_filter, limit=page_size, offset=offset
+            )
+
+            # Print the logs
+            for log in page_logs:
+                app.console.print(
+                    # Print following the flow run format (declared in logging.yml)
+                    f"{pendulum.instance(log.timestamp).to_datetime_string()}.{log.timestamp.microsecond // 1000:03d} | {logging.getLevelName(log.level):7s} | Flow run {flow_run.name!r} - {log.message}",
+                    soft_wrap=True,
+                )
+
+            if len(page_logs) == page_size:
+                offset += page_size
+            else:
+                # No more logs to show, exit
+                more_logs = False


Test patch:
diff --git a/tests/cli/test_flow_run.py b/tests/cli/test_flow_run.py
--- a/tests/cli/test_flow_run.py
+++ b/tests/cli/test_flow_run.py
@@ -1,9 +1,11 @@
+from datetime import datetime, timezone
 from uuid import UUID, uuid4
 
 import pytest
 
 import prefect.exceptions
 from prefect import flow
+from prefect.orion.schemas.actions import LogCreate
 from prefect.states import (
     AwaitingRetry,
     Cancelled,
@@ -275,3 +277,98 @@ def test_wrong_id_exits_with_error(self):
             expected_code=1,
             expected_output_contains=f"Flow run '{bad_id}' not found!\n",
         )
+
+
+class TestFlowRunLogs:
+    PAGE_SIZE = 200
+
+    @pytest.mark.parametrize("state", [Completed, Failed, Crashed, Cancelled])
+    async def test_when_num_logs_smaller_than_page_size_then_no_pagination(
+        self, orion_client, state
+    ):
+        # Given
+        flow_run = await orion_client.create_flow_run(
+            name="scheduled_flow_run", flow=hello_flow, state=state()
+        )
+
+        # Create not enough flow run logs to result in pagination (page_size <= 200)
+        logs = [
+            LogCreate(
+                name="prefect.flow_runs",
+                level=20,
+                message=f"Log {i} from flow_run {flow_run.id}.",
+                timestamp=datetime.now(tz=timezone.utc),
+                flow_run_id=flow_run.id,
+            )
+            for i in range(self.PAGE_SIZE - 1)
+        ]
+        await orion_client.create_logs(logs)
+
+        # When/Then
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=[
+                "flow-run",
+                "logs",
+                str(flow_run.id),
+            ],
+            expected_code=0,
+            expected_output_contains=[
+                f"Flow run '{flow_run.name}' - Log {i} from flow_run {flow_run.id}."
+                for i in range(self.PAGE_SIZE - 1)
+            ],
+        )
+
+    @pytest.mark.parametrize("state", [Completed, Failed, Crashed, Cancelled])
+    async def test_when_num_logs_greater_than_page_size_then_pagination(
+        self, orion_client, state
+    ):
+        # Given
+        flow_run = await orion_client.create_flow_run(
+            name="scheduled_flow_run", flow=hello_flow, state=state()
+        )
+
+        # Create enough flow run logs to result in pagination (page_size > 200)
+        logs = [
+            LogCreate(
+                name="prefect.flow_runs",
+                level=20,
+                message=f"Log {i} from flow_run {flow_run.id}.",
+                timestamp=datetime.now(tz=timezone.utc),
+                flow_run_id=flow_run.id,
+            )
+            for i in range(self.PAGE_SIZE + 1)
+        ]
+        await orion_client.create_logs(logs)
+
+        # When/Then
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=[
+                "flow-run",
+                "logs",
+                str(flow_run.id),
+            ],
+            expected_code=0,
+            expected_output_contains=[
+                f"Flow run '{flow_run.name}' - Log {i} from flow_run {flow_run.id}."
+                for i in range(self.PAGE_SIZE + 1)
+            ],
+        )
+
+    @pytest.mark.parametrize("state", [Completed, Failed, Crashed, Cancelled])
+    async def test_when_flow_run_not_found_then_exit_with_error(self, state):
+        # Given
+        bad_id = str(uuid4())
+
+        # When/Then
+        await run_sync_in_worker_thread(
+            invoke_and_assert,
+            command=[
+                "flow-run",
+                "logs",
+                bad_id,
+            ],
+            expected_code=1,
+            expected_output_contains=f"Flow run '{bad_id}' not found!\n",
+        )