From cb02721f24ef2babd3c34cdff04d6b95adc282d2 Mon Sep 17 00:00:00 2001 From: Mike Shultz Date: Wed, 23 Oct 2024 11:58:58 -0600 Subject: [PATCH] feat: updates bot logs command to support new interface (#149) * feat: updates bot logs command to support new interface * fix: datetime.UTC was introduced in 3.11 * fix: add __future__.annotations import for Self back-support * fix: import Self from typing_extensions * style: cleanup, use Ape's LogLevel * fix: bad import --- silverback/_cli.py | 40 ++++++++++++++++++++++++++++++------ silverback/cluster/client.py | 27 ++++++++++++++++++++---- silverback/cluster/types.py | 12 +++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/silverback/_cli.py b/silverback/_cli.py index 337b2300..58a252c9 100644 --- a/silverback/_cli.py +++ b/silverback/_cli.py @@ -2,7 +2,7 @@ import os import shlex import subprocess -from datetime import timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path import click @@ -31,7 +31,7 @@ token_amount_callback, ) from silverback.cluster.client import ClusterClient, PlatformClient -from silverback.cluster.types import ClusterTier, ResourceStatus +from silverback.cluster.types import ClusterTier, LogLevel, ResourceStatus from silverback.runner import PollingRunner, WebsocketRunner from silverback.worker import run_worker @@ -120,7 +120,8 @@ def run(cli_ctx, account, runner_class, recorder_class, max_exceptions, bot): runner_class = PollingRunner else: raise click.BadOptionUsage( - option_name="network", message="Network choice cannot support running bot" + option_name="network", + message="Network choice cannot support running bot", ) runner = runner_class( @@ -177,7 +178,11 @@ def build(generate, path): f"-t {file.name.split('.')[1]}:latest ." ) result = subprocess.run( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, ) click.echo(result.stdout) except subprocess.CalledProcessError as e: @@ -1086,14 +1091,37 @@ def stop_bot(cluster: ClusterClient, name: str): @bots.command(name="logs", section="Bot Operation Commands") @click.argument("name", metavar="BOT") +@click.option( + "-l", + "--log-level", + "log_level", + help="Minimum log level to display.", + default="INFO", +) +@click.option( + "-s", + "--since", + "since", + help="Return logs since N ago.", + callback=timedelta_callback, +) @cluster_client -def show_bot_logs(cluster: ClusterClient, name: str): +def show_bot_logs(cluster: ClusterClient, name: str, log_level: str, since: timedelta | None): """Show runtime logs for BOT in CLUSTER""" + start_time = None + if since: + start_time = datetime.now(tz=timezone.utc) - since + if not (bot := cluster.bots.get(name)): raise click.UsageError(f"Unknown bot '{name}'.") - for log in bot.logs: + try: + level = LogLevel.__dict__[log_level.upper()] + except KeyError: + level = LogLevel.INFO + + for log in bot.filter_logs(log_level=level, start_time=start_time): click.echo(log) diff --git a/silverback/cluster/client.py b/silverback/cluster/client.py index 9240e140..0c4d066c 100644 --- a/silverback/cluster/client.py +++ b/silverback/cluster/client.py @@ -1,9 +1,11 @@ +from datetime import datetime from functools import cache from typing import ClassVar, Literal import httpx from ape import Contract from ape.contracts import ContractInstance +from ape.logging import LogLevel from apepay import Stream, StreamManager from pydantic import computed_field @@ -12,6 +14,7 @@ from .types import ( BotHealth, BotInfo, + BotLogEntry, ClusterHealth, ClusterInfo, ClusterState, @@ -189,11 +192,27 @@ def errors(self) -> list[str]: handle_error_with_response(response) return response.json() - @property - def logs(self) -> list[str]: - response = self.cluster.get(f"/bots/{self.id}/logs") + def filter_logs( + self, + log_level: LogLevel = LogLevel.INFO, + start_time: datetime | None = None, + end_time: datetime | None = None, + ) -> list[BotLogEntry]: + query = {"log_level": log_level.name} + + if start_time: + query["start_time"] = start_time.isoformat() + + if end_time: + query["end_time"] = end_time.isoformat() + + response = self.cluster.get(f"/bots/{self.id}/logs", params=query, timeout=120) handle_error_with_response(response) - return response.json() + return [BotLogEntry.model_validate(log) for log in response.json()] + + @property + def logs(self) -> list[BotLogEntry]: + return self.filter_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 2765f11e..54049128 100644 --- a/silverback/cluster/types.py +++ b/silverback/cluster/types.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import enum import math import uuid from datetime import datetime from typing import Annotated, Any +from ape.logging import LogLevel from ape.types import AddressType, HexBytes from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.hmac import HMAC, hashes @@ -374,3 +377,12 @@ class BotInfo(BaseModel): registry_credentials_id: str | None environment: list[EnvironmentVariable] = [] + + +class BotLogEntry(BaseModel): + message: str + timestamp: datetime | None + level: LogLevel + + def __str__(self) -> str: + return f"{self.timestamp}: {self.message}"