1- import json
21import logging
3- import time
4- from collections .abc import Generator
52from datetime import datetime
63from pathlib import Path
74from typing import Annotated , Optional
85
96import typer
10- from httpx import HTTPError , HTTPStatusError , ReadTimeout
11- from pydantic import BaseModel , ValidationError
127from rich .markup import escape
138from rich_toolkit import RichToolkit
149
15- from fastapi_cloud_cli .utils .api import APIClient
10+ from fastapi_cloud_cli .utils .api import (
11+ APIClient ,
12+ AppLogEntry ,
13+ StreamLogError ,
14+ TooManyRetriesError ,
15+ )
1616from fastapi_cloud_cli .utils .apps import AppConfig , get_app_config
1717from fastapi_cloud_cli .utils .auth import is_logged_in
1818from fastapi_cloud_cli .utils .cli import get_rich_toolkit
1919
2020logger = logging .getLogger (__name__ )
2121
22- MAX_RECONNECT_ATTEMPTS = 10
23- RECONNECT_DELAY_SECONDS = 1
22+
2423LOG_LEVEL_COLORS = {
2524 "debug" : "blue" ,
2625 "info" : "cyan" ,
3231}
3332
3433
35- class LogEntry (BaseModel ):
36- timestamp : datetime
37- message : str
38- level : str = "unknown"
39-
40-
41- def _stream_logs (
42- app_id : str ,
43- tail : int ,
44- since : str ,
45- follow : bool ,
46- ) -> Generator [str , None , None ]:
47- with APIClient () as client :
48- timeout = 120 if follow else 30
49- with client .stream (
50- "GET" ,
51- f"/apps/{ app_id } /logs/stream" ,
52- params = {
53- "tail" : tail ,
54- "since" : since ,
55- "follow" : follow ,
56- },
57- timeout = timeout ,
58- ) as response :
59- response .raise_for_status ()
60-
61- yield from response .iter_lines ()
62-
63-
64- def _format_log_line (log : LogEntry ) -> str :
34+ def _format_log_line (log : AppLogEntry ) -> str :
6535 """Format a log entry for display with a colored indicator"""
66- timestamp_str = log .timestamp .strftime ("%Y-%m-%dT%H:%M:%S.%f" )[:- 3 ] + "Z"
36+ # Parse the timestamp string to format it consistently
37+ timestamp = datetime .fromisoformat (log .timestamp .replace ("Z" , "+00:00" ))
38+ timestamp_str = timestamp .strftime ("%Y-%m-%dT%H:%M:%S.%f" )[:- 3 ] + "Z"
6739 color = LOG_LEVEL_COLORS .get (log .level .lower ())
6840
6941 message = escape (log .message )
@@ -81,104 +53,46 @@ def _process_log_stream(
8153 since : str ,
8254 follow : bool ,
8355) -> None :
56+ """Stream app logs and print them to the console."""
8457 log_count = 0
85- last_timestamp : datetime | None = None
86- current_since = since
87- current_tail = tail
88- reconnect_attempts = 0
89-
90- while True :
91- try :
92- for line in _stream_logs (
58+
59+ try :
60+ with APIClient () as client :
61+ for log in client .stream_app_logs (
9362 app_id = app_config .app_id ,
94- tail = current_tail ,
95- since = current_since ,
63+ tail = tail ,
64+ since = since ,
9665 follow = follow ,
9766 ):
98- if not line : # pragma: no cover
99- continue
100-
101- try :
102- data = json .loads (line )
103- except json .JSONDecodeError :
104- logger .debug ("Failed to parse log line: %s" , line )
105- continue
106-
107- # Skip heartbeat messages
108- if data .get ("type" ) == "heartbeat" : # pragma: no cover
109- continue
110-
111- if data .get ("type" ) == "error" :
112- toolkit .print (
113- f"Error: { data .get ('message' , 'Unknown error' )} " ,
114- )
115- raise typer .Exit (1 )
116-
117- # Parse and display log entry
118- try :
119- log_entry = LogEntry .model_validate (data )
120- toolkit .print (_format_log_line (log_entry ))
121- log_count += 1
122- last_timestamp = log_entry .timestamp
123- # Reset reconnect attempts on successful log receipt
124- reconnect_attempts = 0
125- except ValidationError as e : # pragma: no cover
126- logger .debug ("Failed to parse log entry: %s - %s" , data , e )
127- continue
128-
129- # Stream ended normally (only happens with --no-follow)
67+ toolkit .print (_format_log_line (log ))
68+ log_count += 1
69+
13070 if not follow and log_count == 0 :
13171 toolkit .print ("No logs found for the specified time range." )
132- break
133-
134- except KeyboardInterrupt : # pragma: no cover
135- toolkit .print_line ()
136- break
137- except (ReadTimeout , HTTPError ) as e :
138- # In follow mode, try to reconnect on connection issues
139- if follow and not isinstance (e , HTTPStatusError ):
140- reconnect_attempts += 1
141- if reconnect_attempts >= MAX_RECONNECT_ATTEMPTS :
142- toolkit .print (
143- "Lost connection to log stream. Please try again later." ,
144- )
145- raise typer .Exit (1 ) from None
146-
147- logger .debug (
148- "Connection lost, reconnecting (attempt %d/%d)..." ,
149- reconnect_attempts ,
150- MAX_RECONNECT_ATTEMPTS ,
151- )
152-
153- # On reconnect, resume from last seen timestamp
154- # The API uses strict > comparison, so logs with the same timestamp
155- # as last_timestamp will be filtered out (no duplicates)
156- if last_timestamp : # pragma: no cover
157- current_since = last_timestamp .isoformat ()
158- current_tail = 0 # Don't fetch historical logs again
159-
160- time .sleep (RECONNECT_DELAY_SECONDS )
161- continue
162-
163- if isinstance (e , HTTPStatusError ) and e .response .status_code in (401 , 403 ):
164- toolkit .print (
165- "The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token." ,
166- )
167- if isinstance (e , HTTPStatusError ) and e .response .status_code == 404 :
168- toolkit .print (
169- "App not found. Make sure to use the correct account." ,
170- )
171- elif isinstance (e , ReadTimeout ):
172- toolkit .print (
173- "The request timed out. Please try again later." ,
174- )
175- else :
176- logger .exception ("Failed to fetch logs" )
177-
178- toolkit .print (
179- "Failed to fetch logs. Please try again later." ,
180- )
181- raise typer .Exit (1 ) from None
72+ return
73+ except KeyboardInterrupt : # pragma: no cover
74+ toolkit .print_line ()
75+ return
76+ except StreamLogError as e :
77+ error_msg = str (e )
78+ if "HTTP 401" in error_msg or "HTTP 403" in error_msg :
79+ toolkit .print (
80+ "The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token." ,
81+ )
82+ elif "HTTP 404" in error_msg :
83+ toolkit .print (
84+ "App not found. Make sure to use the correct account." ,
85+ )
86+ else :
87+ toolkit .print (
88+ f"[red]Error:[/] { escape (error_msg )} " ,
89+ )
90+ raise typer .Exit (1 ) from None
91+ except (TooManyRetriesError , TimeoutError ):
92+ toolkit .print (
93+ "Lost connection to log stream. Please try again later." ,
94+ )
95+ raise typer .Exit (1 ) from None
18296
18397
18498def logs (
0 commit comments