Skip to content

Commit eda6610

Browse files
fastapi cloud logs (#132)
* Initial commit for logs command * Refactor * Add more tests * More cleanup for style * String cleanup * Appease linter * Use 3.9 syntax * Clean up after merge * Clean up tests * Add escape * Handle 404 * Refactor to use attempts * Use identity * Improve coverage * Validate since * Improve docstring --------- Co-authored-by: Patrick Arminio <patrick.arminio@gmail.com>
1 parent 7a40363 commit eda6610

File tree

7 files changed

+631
-16
lines changed

7 files changed

+631
-16
lines changed

src/fastapi_cloud_cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .commands.env import env_app
55
from .commands.login import login
66
from .commands.logout import logout
7+
from .commands.logs import logs
78
from .commands.unlink import unlink
89
from .commands.whoami import whoami
910
from .logging import setup_logging
@@ -25,6 +26,7 @@
2526
# fastapi cloud [command]
2627
cloud_app.command()(deploy)
2728
cloud_app.command()(login)
29+
cloud_app.command()(logs)
2830
cloud_app.command()(logout)
2931
cloud_app.command()(whoami)
3032
cloud_app.command()(unlink)

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from rich_toolkit.menu import Option
2020

2121
from fastapi_cloud_cli.commands.login import login
22-
from fastapi_cloud_cli.utils.api import APIClient, BuildLogError, TooManyRetriesError
22+
from fastapi_cloud_cli.utils.api import APIClient, StreamLogError, TooManyRetriesError
2323
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
2424
from fastapi_cloud_cli.utils.auth import Identity
2525
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -429,7 +429,7 @@ def _wait_for_deployment(
429429

430430
last_message_changed_at = time.monotonic()
431431

432-
except (BuildLogError, TooManyRetriesError, TimeoutError) as e:
432+
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
433433
progress.set_error(
434434
dedent(f"""
435435
[error]Build log streaming failed: {e}[/]
@@ -438,7 +438,7 @@ def _wait_for_deployment(
438438
""").strip()
439439
)
440440

441-
raise typer.Exit(1) from e
441+
raise typer.Exit(1) from None
442442

443443

444444
class SignupToWaitingList(BaseModel):
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import logging
2+
import re
3+
from datetime import datetime
4+
from pathlib import Path
5+
from typing import Annotated, Optional
6+
7+
import typer
8+
from rich.markup import escape
9+
from rich_toolkit import RichToolkit
10+
11+
from fastapi_cloud_cli.utils.api import (
12+
APIClient,
13+
AppLogEntry,
14+
StreamLogError,
15+
TooManyRetriesError,
16+
)
17+
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config
18+
from fastapi_cloud_cli.utils.auth import Identity
19+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
LOG_LEVEL_COLORS = {
25+
"debug": "blue",
26+
"info": "cyan",
27+
"warning": "yellow",
28+
"warn": "yellow",
29+
"error": "red",
30+
"critical": "magenta",
31+
"fatal": "magenta",
32+
}
33+
34+
SINCE_PATTERN = re.compile(r"^\d+[smhd]$")
35+
36+
37+
def _validate_since(value: str) -> str:
38+
"""Validate the --since parameter format."""
39+
if not SINCE_PATTERN.match(value):
40+
raise typer.BadParameter(
41+
"Invalid format. Use a number followed by s, m, h, or d (e.g., '5m', '1h', '2d')."
42+
)
43+
44+
return value
45+
46+
47+
def _format_log_line(log: AppLogEntry) -> str:
48+
"""Format a log entry for display with a colored indicator"""
49+
# Parse the timestamp string to format it consistently
50+
timestamp = datetime.fromisoformat(log.timestamp.replace("Z", "+00:00"))
51+
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
52+
color = LOG_LEVEL_COLORS.get(log.level.lower())
53+
54+
message = escape(log.message)
55+
56+
if color:
57+
return f"[{color}]┃[/{color}] [dim]{timestamp_str}[/dim] {message}"
58+
59+
return f"[dim]┃[/dim] [dim]{timestamp_str}[/dim] {message}"
60+
61+
62+
def _process_log_stream(
63+
toolkit: RichToolkit,
64+
app_config: AppConfig,
65+
tail: int,
66+
since: str,
67+
follow: bool,
68+
) -> None:
69+
"""Stream app logs and print them to the console."""
70+
log_count = 0
71+
72+
try:
73+
with APIClient() as client:
74+
for log in client.stream_app_logs(
75+
app_id=app_config.app_id,
76+
tail=tail,
77+
since=since,
78+
follow=follow,
79+
):
80+
toolkit.print(_format_log_line(log))
81+
log_count += 1
82+
83+
if not follow and log_count == 0:
84+
toolkit.print("No logs found for the specified time range.")
85+
return
86+
except KeyboardInterrupt: # pragma: no cover
87+
toolkit.print_line()
88+
return
89+
except StreamLogError as e:
90+
error_msg = str(e)
91+
if "HTTP 401" in error_msg or "HTTP 403" in error_msg:
92+
toolkit.print(
93+
"The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token.",
94+
)
95+
elif "HTTP 404" in error_msg:
96+
toolkit.print(
97+
"App not found. Make sure to use the correct account.",
98+
)
99+
else:
100+
toolkit.print(
101+
f"[red]Error:[/] {escape(error_msg)}",
102+
)
103+
raise typer.Exit(1) from None
104+
except (TooManyRetriesError, TimeoutError):
105+
toolkit.print(
106+
"Lost connection to log stream. Please try again later.",
107+
)
108+
raise typer.Exit(1) from None
109+
110+
111+
def logs(
112+
path: Annotated[
113+
Optional[Path],
114+
typer.Argument(
115+
help="Path to the folder containing the app (defaults to current directory)"
116+
),
117+
] = None,
118+
tail: int = typer.Option(
119+
100,
120+
"--tail",
121+
"-t",
122+
help="Number of log lines to show before streaming.",
123+
show_default=True,
124+
),
125+
since: str = typer.Option(
126+
"5m",
127+
"--since",
128+
"-s",
129+
help="Show logs since a specific time (e.g., '5m', '1h', '2d').",
130+
show_default=True,
131+
callback=_validate_since,
132+
),
133+
follow: bool = typer.Option(
134+
True,
135+
"--follow/--no-follow",
136+
"-f",
137+
help="Stream logs in real-time (use --no-follow to fetch and exit).",
138+
),
139+
) -> None:
140+
"""Stream or fetch logs from your deployed app.
141+
142+
Examples:
143+
fastapi cloud logs # Stream logs in real-time
144+
fastapi cloud logs --no-follow # Fetch recent logs and exit
145+
fastapi cloud logs --tail 50 --since 1h # Last 50 logs from the past hour
146+
"""
147+
identity = Identity()
148+
with get_rich_toolkit(minimal=True) as toolkit:
149+
if not identity.is_logged_in():
150+
toolkit.print(
151+
"No credentials found. Use [blue]`fastapi login`[/] to login.",
152+
tag="auth",
153+
)
154+
raise typer.Exit(1)
155+
156+
app_path = path or Path.cwd()
157+
app_config = get_app_config(app_path)
158+
159+
if not app_config:
160+
toolkit.print(
161+
"No app linked to this directory. Run [blue]`fastapi deploy`[/] first.",
162+
)
163+
raise typer.Exit(1)
164+
165+
logger.debug("Fetching logs for app ID: %s", app_config.app_id)
166+
167+
if follow:
168+
toolkit.print(
169+
f"Streaming logs for [bold]{app_config.app_id}[/bold] (Ctrl+C to exit)...",
170+
tag="logs",
171+
)
172+
else:
173+
toolkit.print(
174+
f"Fetching logs for [bold]{app_config.app_id}[/bold]...",
175+
tag="logs",
176+
)
177+
toolkit.print_line()
178+
179+
_process_log_stream(
180+
toolkit=toolkit,
181+
app_config=app_config,
182+
tail=tail,
183+
since=since,
184+
follow=follow,
185+
)

src/fastapi_cloud_cli/utils/api.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,26 @@
2525

2626
logger = logging.getLogger(__name__)
2727

28-
BUILD_LOG_MAX_RETRIES = 3
29-
BUILD_LOG_TIMEOUT = timedelta(minutes=5)
28+
STREAM_LOGS_MAX_RETRIES = 3
29+
STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
3030

3131

32-
class BuildLogError(Exception):
32+
class StreamLogError(Exception):
33+
"""Raised when there's an error streaming logs (build or app logs)."""
34+
3335
pass
3436

3537

3638
class TooManyRetriesError(Exception):
3739
pass
3840

3941

42+
class AppLogEntry(BaseModel):
43+
timestamp: str
44+
message: str
45+
level: str
46+
47+
4048
class BuildLogLineGeneric(BaseModel):
4149
type: Literal["complete", "failed", "timeout", "heartbeat"]
4250
id: Optional[str] = None
@@ -91,7 +99,7 @@ def _backoff() -> None:
9199
error_detail = error.response.text
92100
except Exception:
93101
error_detail = "(response body unavailable)"
94-
raise BuildLogError(
102+
raise StreamLogError(
95103
f"HTTP {error.response.status_code}: {error_detail}"
96104
) from error
97105

@@ -115,7 +123,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
115123
for attempt_number in range(total_attempts):
116124
if time.monotonic() - start > timeout.total_seconds():
117125
raise TimeoutError(
118-
f"Build log streaming timed out after {timeout.total_seconds():.0f}s"
126+
f"Log streaming timed out after {timeout.total_seconds():.0f}s"
119127
)
120128

121129
with attempt(attempt_number):
@@ -144,7 +152,7 @@ def __init__(self) -> None:
144152
},
145153
)
146154

147-
@attempts(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT)
155+
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
148156
def stream_build_logs(
149157
self, deployment_id: str
150158
) -> Generator[BuildLogLine, None, None]:
@@ -192,3 +200,44 @@ def _parse_log_line(self, line: str) -> Optional[BuildLogLine]:
192200
except (ValidationError, json.JSONDecodeError) as e:
193201
logger.debug("Skipping malformed log: %s (error: %s)", line[:100], e)
194202
return None
203+
204+
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
205+
def stream_app_logs(
206+
self,
207+
app_id: str,
208+
tail: int,
209+
since: str,
210+
follow: bool,
211+
) -> Generator[AppLogEntry, None, None]:
212+
timeout = 120 if follow else 30
213+
with self.stream(
214+
"GET",
215+
f"/apps/{app_id}/logs/stream",
216+
params={
217+
"tail": tail,
218+
"since": since,
219+
"follow": follow,
220+
},
221+
timeout=timeout,
222+
) as response:
223+
response.raise_for_status()
224+
for line in response.iter_lines():
225+
if not line or not line.strip(): # pragma: no cover
226+
continue
227+
try:
228+
data = json.loads(line)
229+
except json.JSONDecodeError:
230+
logger.debug("Failed to parse log line: %s", line)
231+
continue
232+
233+
if data.get("type") == "heartbeat":
234+
continue
235+
236+
if data.get("type") == "error":
237+
raise StreamLogError(data.get("message", "Unknown error"))
238+
239+
try:
240+
yield AppLogEntry.model_validate(data)
241+
except ValidationError as e: # pragma: no cover
242+
logger.debug("Failed to parse log entry: %s - %s", data, e)
243+
continue

tests/test_api_client.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99

1010
from fastapi_cloud_cli.config import Settings
1111
from fastapi_cloud_cli.utils.api import (
12-
BUILD_LOG_MAX_RETRIES,
12+
STREAM_LOGS_MAX_RETRIES,
1313
APIClient,
14-
BuildLogError,
1514
BuildLogLineMessage,
15+
StreamLogError,
1616
TooManyRetriesError,
1717
)
1818
from tests.utils import build_logs_response
@@ -243,7 +243,7 @@ def test_stream_build_logs_client_error_raises_immediately(
243243
) -> None:
244244
logs_route.mock(return_value=Response(404, text="Not Found"))
245245

246-
with pytest.raises(BuildLogError, match="HTTP 404"):
246+
with pytest.raises(StreamLogError, match="HTTP 404"):
247247
list(client.stream_build_logs(deployment_id))
248248

249249

@@ -255,7 +255,8 @@ def test_stream_build_logs_max_retries_exceeded(
255255

256256
with patch("time.sleep"):
257257
with pytest.raises(
258-
TooManyRetriesError, match=f"Failed after {BUILD_LOG_MAX_RETRIES} attempts"
258+
TooManyRetriesError,
259+
match=f"Failed after {STREAM_LOGS_MAX_RETRIES} attempts",
259260
):
260261
list(client.stream_build_logs(deployment_id))
261262

@@ -343,7 +344,7 @@ def test_stream_build_logs_connection_closed_without_complete_failed_or_timeout(
343344
logs = client.stream_build_logs(deployment_id)
344345

345346
with patch("time.sleep"), pytest.raises(TooManyRetriesError, match="Failed after"):
346-
for _ in range(BUILD_LOG_MAX_RETRIES + 1):
347+
for _ in range(STREAM_LOGS_MAX_RETRIES + 1):
347348
next(logs)
348349

349350

tests/test_cli_deploy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from fastapi_cloud_cli.cli import app
1717
from fastapi_cloud_cli.config import Settings
18-
from fastapi_cloud_cli.utils.api import BuildLogError, TooManyRetriesError
18+
from fastapi_cloud_cli.utils.api import StreamLogError, TooManyRetriesError
1919
from tests.conftest import ConfiguredApp
2020
from tests.utils import Keys, build_logs_response, changing_dir
2121

@@ -823,7 +823,7 @@ def test_shows_no_apps_found_message_when_team_has_no_apps(
823823

824824
@pytest.mark.parametrize(
825825
"error",
826-
[BuildLogError, TooManyRetriesError, TimeoutError],
826+
[StreamLogError, TooManyRetriesError, TimeoutError],
827827
)
828828
@pytest.mark.respx(base_url=settings.base_api_url)
829829
def test_shows_error_message_on_build_exception(

0 commit comments

Comments
 (0)