diff --git a/silverback/_cli.py b/silverback/_cli.py index a47a01fd..9d361bdc 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -1316,8 +1316,21 @@ def stop_bot(cluster: "ClusterClient", name: str): help="Return logs since N ago.", callback=timedelta_callback, ) +@click.option( + "-f", + "--follow", + help="Stream logs as they come in", + is_flag=True, + default=False, +) @cluster_client -def show_bot_logs(cluster: "ClusterClient", name: str, log_level: str, since: timedelta | None): +def show_bot_logs( + cluster: "ClusterClient", + name: str, + log_level: str, + since: timedelta | None, + follow: bool, +): """Show runtime logs for BOT in CLUSTER""" start_time = None @@ -1332,8 +1345,8 @@ def show_bot_logs(cluster: "ClusterClient", name: str, log_level: str, since: ti except KeyError: level = LogLevel.INFO - for log in bot.filter_logs(log_level=level, start_time=start_time): - click.echo(log) + for log in bot.get_logs(log_level=level, start_time=start_time, follow=follow): + click.echo(str(log)) @bots.command(name="errors", section="Bot Operation Commands") diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index a05ca748..4bda0a40 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -179,7 +179,7 @@ def errors(self) -> list[str]: handle_error_with_response(response) return response.json() - def filter_logs( + def get_logs( self, log_level: LogLevel = LogLevel.INFO, start_time: datetime | None = None, @@ -201,7 +201,7 @@ def filter_logs( @property def logs(self) -> list[BotLogEntry]: - return list(self.filter_logs()) + return list(self.get_logs()) def remove(self): response = self.cluster.delete(f"/bots/{self.id}") diff --git a/silverback/cluster/types.py b/silverback/cluster/types.py index 082359be..b09fabbc 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -2,16 +2,18 @@ import enum import math +import re import uuid from datetime import datetime -from typing import Annotated, Any +from typing import Annotated, Any, ClassVar -from ape.logging import LogLevel +from ape.logging import CLICK_STYLE_KWARGS, LogLevel from ape.types import AddressType, HexBytes from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.hmac import HMAC, hashes from eth_utils import to_bytes, to_int from pydantic import BaseModel, Field, computed_field, field_validator +from typing_extensions import Self def normalize_bytes(val: bytes, length: int = 16) -> bytes: @@ -350,9 +352,53 @@ class BotInfo(BaseModel): class BotLogEntry(BaseModel): - level: LogLevel = LogLevel.INFO + LOG_PATTERN: ClassVar[re.Pattern] = re.compile( + r"""^ + (?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)\s + (?: + (?PDEBUG:\s\s\s|INFO:\s\s\s\s|SUCCESS:\s|WARNING:\s|ERROR:\s\s\s|CRITICAL:)\s + )? + (?P.*)$""", + re.VERBOSE, + ) + + level: LogLevel | None = None timestamp: datetime | None = None message: str + @classmethod + def parse_line(cls, line: str) -> Self: + # Typical line is like: `{timestamp} {str(log_level) + ':':<9} {message}` + if not (match := cls.LOG_PATTERN.match(line)): + return cls(message=line) + + if level := match.group("level"): + level = LogLevel[level.strip()[:-1]] + + return cls( + timestamp=match.group("timestamp"), + level=level, + message=match.group("message"), + ) + def __str__(self) -> str: - return f"{self.timestamp} [{self.level}]: {self.message}" + from click import style as click_style + + if self.level is not None: + styles = CLICK_STYLE_KWARGS.get(self.level, {}) + level_str = click_style(f"{self.level.name:<8}", **styles) # type: ignore[arg-type] + else: + level_str = "" + + if self.timestamp is not None: + timestamp_str = click_style(f"{self.timestamp:%x %X}", bold=True) + else: + timestamp_str = "" + + # NOTE: Add offset (18+8+2=28) to all newlines in message after the first + if "\n" in (message := self.message): + message = (" " * 28 + "\n").join(message.split("\n")) + + # NOTE: Max size of `LogLevel` is 8 chars + # NOTE: Max size of normalized timestamp is 18 chars + return f"{timestamp_str:<18} {level_str:<8} | {message}"