Source code for smarter.apps.mcpserver.logs.tools

"""
The operations tools of the platform's pod logs: ``list_log_sources``, ``search_logs`` and ``summarize_log_errors``.

They read :data:`smarter.apps.infrastructure.services.infrastructure` ``.logs``: the last 72 hours
of the app (``smarter``), the Celery workers (``smarter-worker``, ``smarter-worker-infrastructure``),
beat (``smarter-beat``) and MariaDB (``mariadb``). Each query is structured, not LogQL, so the LLM
cannot inject a query of its own.

They are tools of the staff-only Smarter Operations MCP server, built on
:class:`~smarter.apps.mcpserver.protocol.operations_tools.SmarterOperationsTool`, which records every
call in the audit trail. A result that quotes log lines starts with
:data:`~smarter.apps.mcpserver.protocol.operations_tools.UNTRUSTED_DATA_NOTICE`, because a log line
can carry text that a user wrote.

**Side effects:** each call makes one or two HTTP GETs to Loki (in the tests, to the in-memory log
service), and the audit trail's database write. Nothing is cached.

.. note::

    **Experimental.** The MCPServer was designed and coded by Claude Code (Anthropic's
    Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
    be documented.
"""

from datetime import datetime, timedelta, timezone
from typing import Literal, Optional

from pydantic import Field, ValidationError

from smarter.apps.infrastructure.services import infrastructure
from smarter.apps.infrastructure.services.logs import (
    LOG_RETENTION,
    LogLevel,
    LogQuery,
    at_least,
)
from smarter.apps.mcpserver.exceptions import SmarterMCPServerToolError
from smarter.apps.mcpserver.logs.summary import error_groups, format_line
from smarter.apps.mcpserver.protocol.operations_tools import (
    UNTRUSTED_DATA_NOTICE,
    OperationsToolArguments,
    SmarterOperationsTool,
)

COMPONENTS = ("smarter", "smarter-worker", "smarter-worker-infrastructure", "smarter-beat", "mariadb")
"""The components whose logs are kept: the ``app.kubernetes.io/name`` of their pods."""

Component = Literal["smarter", "smarter-worker", "smarter-worker-infrastructure", "smarter-beat", "mariadb"]
"""
A component's name, as a tool argument: one of :data:`COMPONENTS`.

A ``Literal`` puts the names in the tool's ``inputSchema`` as an ``enum``, so that the LLM sees
them, and Pydantic refuses any other.
"""

MAX_MINUTES = int(LOG_RETENTION.total_seconds() // 60)
"""The longest period that a tool reads: the log store's retention."""

SUMMARY_LINES = 5000
"""The most warning and error lines that ``summarize_log_errors`` groups."""

COMPONENTS_DESCRIPTION = (
    "The components to read: smarter (the web app and api), smarter-worker (Celery tasks, e.g. prompts' "
    "background work), smarter-worker-infrastructure (deploys, DNS, certificates, Kubernetes), smarter-beat "
    "(the scheduler) and mariadb (the database). Omit it to read all of them."
)
"""The description of a tool's ``components`` argument, which says what each component does, for the LLM."""


def log_query(**kwargs) -> LogQuery:
    """
    Return a log query, with a validation error as a tool error that the LLM can correct.

    :class:`~smarter.apps.infrastructure.services.logs.LogQuery` validates what the tool's arguments
    model cannot, e.g. that a ``pattern`` compiles, or that a ``pod`` is a Kubernetes name.

    :param kwargs: The query's fields, for
        :meth:`~smarter.apps.infrastructure.services.logs.LogQuery.last`, starting with ``period``.
    :returns: The query, of the most recent period.
    :raises SmarterMCPServerToolError: If the query is not valid.
    """
    try:
        query = LogQuery.last(**kwargs)
    except ValidationError as e:
        messages = [str(problem["msg"]) for problem in e.errors()]
        problems = "; ".join(messages)
        raise SmarterMCPServerToolError(f"invalid log query: {problems}") from e
    return query


class ListLogSourcesArguments(OperationsToolArguments):
    """The arguments of ``list_log_sources``: the ``ticket``, and how far back to look."""

    minutes: int = Field(default=60, ge=1, le=MAX_MINUTES, description="How far back to look, in minutes.")
    """How far back to look, in minutes, from 1 to :data:`MAX_MINUTES`.

    Defaults to an hour.
    """


[docs] class ListLogSourcesTool(SmarterOperationsTool): """ The ``list_log_sources`` tool: the components and pods that logged recently. A component that is missing from the list wrote nothing in the period, which usually means that its pods are down. The pods' names are what ``search_logs``' ``pod`` argument takes. """ name = "list_log_sources" title = "List log sources" description = ( "List the components and pods that wrote to the logs in the last minutes, from the log store, which " "keeps 72 hours. A component that is missing has written nothing, e.g. because its pods are down. " "Pod names change on every deploy and restart: use them with search_logs' pod argument." ) arguments_model = ListLogSourcesArguments
[docs] def run(self, arguments: ListLogSourcesArguments) -> str: # type: ignore[override] """ List the components and pods that wrote to the logs in the period. **Side effects:** two HTTP GETs to Loki, for the values of the ``component`` and ``pod`` labels. :param arguments: The validated arguments. :returns: Markdown: the components, those of :data:`COMPONENTS` that wrote nothing, and the pods. :raises LogServiceError: If Loki could not be reached. :raises InfrastructureNotReadyError: If there is no log store. """ end = datetime.now(timezone.utc) start = end - timedelta(minutes=arguments.minutes) components = infrastructure.logs.label_values("component", start, end) pods = infrastructure.logs.label_values("pod", start, end) missing = [component for component in COMPONENTS if component not in components] lines = [f"## Log sources in the last {arguments.minutes} minutes", ""] if components: component_list = ", ".join(components) else: component_list = "none" lines.append("Components: " + component_list) if missing: lines.append("Components that wrote nothing: " + ", ".join(missing)) lines.append("") lines.append("Pods:") if pods: for pod in pods: lines.append(f"- {pod}") else: lines.append("- none") text = "\n".join(lines) return text
class SearchLogsArguments(OperationsToolArguments): """The arguments of ``search_logs``: the ``ticket``, and the filters of the search, which all apply at once.""" components: list[Component] = Field(default_factory=list, description=COMPONENTS_DESCRIPTION) """The components to read, or an empty list for all of them.""" pod: Optional[str] = Field(default=None, description="One pod's name, from list_log_sources.") """One pod's name, from ``list_log_sources``, or None for every pod of the components.""" contains: list[str] = Field( default_factory=list, max_length=5, description="Text that every line must contain, case insensitively, e.g. ['timeout', 'openai'].", ) """Up to five texts, each of which every line must contain, case insensitively.""" pattern: Optional[str] = Field( default=None, description="A regular expression, in RE2 syntax, that every line must match." ) """A regular expression, in RE2 syntax, that every line must match, or None.""" min_level: Optional[LogLevel] = Field( default=None, description="The least severe level to return, e.g. 'warning' for warnings, errors and critical lines.", ) """The least severe level to return, or None for lines of every level, and lines without one.""" minutes: int = Field(default=60, ge=1, le=MAX_MINUTES, description="How far back to search, in minutes.") """How far back to search, in minutes, from 1 to :data:`MAX_MINUTES`. Defaults to an hour. """ limit: int = Field(default=50, ge=1, le=200, description="The most lines to return.") """The most lines to return, from 1 to 200. Defaults to 50. """ oldest_first: bool = Field( default=False, description="Return the oldest lines of the period first, rather than the newest." ) """Whether to return the oldest lines of the period first. By default the newest are, like ``tail``. """
[docs] class SearchLogsTool(SmarterOperationsTool): """ The ``search_logs`` tool: the lines of the pod logs that match a set of filters. The filters become a :class:`~smarter.apps.infrastructure.services.logs.LogQuery`, which the log service turns into escaped LogQL, so the LLM's text is never run as a query. """ name = "search_logs" title = "Search pod logs" description = ( "Search the logs of the platform's pods, from the log store, which keeps 72 hours. Filter by component, " "pod, text, a regular expression and the least severe level. Returns the newest matching lines first, " "each with its time (UTC), component and pod. With no filters, it returns the latest lines, like tail. " "To find what went wrong, start with summarize_log_errors, then search for a signature it returns." ) arguments_model = SearchLogsArguments
[docs] def run(self, arguments: SearchLogsArguments) -> str: # type: ignore[override] """ Search the pod logs. **Side effects:** one HTTP GET to Loki's ``query_range``. :param arguments: The validated arguments. :returns: Markdown: a heading with the number of lines, then the lines in a code block, after :data:`~smarter.apps.mcpserver.protocol.operations_tools.UNTRUSTED_DATA_NOTICE`, or a hint to widen the search if no line matches. :raises SmarterMCPServerToolError: If the query is not valid, e.g. its pattern does not compile. :raises LogServiceError: If Loki could not be reached, or rejected the query. :raises InfrastructureNotReadyError: If there is no log store. """ if arguments.oldest_first: direction = "forward" order = "oldest first" else: direction = "backward" order = "newest first" query = log_query( period=timedelta(minutes=arguments.minutes), components=tuple(arguments.components), pod=arguments.pod, contains=tuple(arguments.contains), pattern=arguments.pattern, min_level=arguments.min_level, limit=arguments.limit, direction=direction, ) lines = infrastructure.logs.query(query) if arguments.min_level: # Loki matches the level anywhere in the line; keep the lines whose own level it is. lines = [line for line in lines if at_least(line.level, arguments.min_level)] heading = f"## {len(lines)} log lines in the last {arguments.minutes} minutes" if not lines: return f"{heading}\n\nNo line matches. Widen the period, or remove a filter." if len(lines) == arguments.limit: heading += f" (the limit, {order}: there may be more)" else: heading += f" ({order})" formatted_lines = [format_line(line) for line in lines] body = "\n".join(formatted_lines) text = f"{UNTRUSTED_DATA_NOTICE}{heading}\n\n```text\n{body}\n```" return text
class SummarizeLogErrorsArguments(OperationsToolArguments): """The arguments of ``summarize_log_errors``: the ``ticket``, the components, the period, the level and the number of groups.""" components: list[Component] = Field(default_factory=list, description=COMPONENTS_DESCRIPTION) """The components to read, or an empty list for all of them.""" minutes: int = Field(default=60, ge=1, le=MAX_MINUTES, description="How far back to look, in minutes.") """How far back to look, in minutes, from 1 to :data:`MAX_MINUTES`. Defaults to an hour. """ min_level: Literal["warning", "error"] = Field( default="error", description="'error' for errors and critical lines, 'warning' to include warnings." ) """``error`` for errors and critical lines, the default, or ``warning`` to include warnings.""" groups: int = Field(default=15, ge=1, le=50, description="The most groups to return.") """The most groups to show, from 1 to 50. Defaults to 15. The result says how many more there are. """
[docs] class SummarizeLogErrorsTool(SmarterOperationsTool): """ The ``summarize_log_errors`` tool: the errors of the pod logs, grouped by their signature. It reads up to :data:`SUMMARY_LINES` warning and error lines, and groups them with :func:`~smarter.apps.mcpserver.logs.summary.error_groups`, so that the LLM sees each problem once, with how often it happened, rather than thousands of lines. """ name = "summarize_log_errors" title = "Summarize log errors" description = ( "Summarize the errors, and optionally the warnings, in the platform's pod logs over the last minutes: " "the lines are grouped by component, level and signature (the message without its numbers, ids and " "quoted values), and each group has its count, its pods, when it was first and last seen, and its " "latest line. Call it first when a user reports errors, slowness or an outage, then use search_logs " "for the lines around a group." ) arguments_model = SummarizeLogErrorsArguments
[docs] def run(self, arguments: SummarizeLogErrorsArguments) -> str: # type: ignore[override] """ Group the errors, and optionally the warnings, of the period. **Side effects:** one HTTP GET to Loki's ``query_range``. :param arguments: The validated arguments. :returns: Markdown: a heading with the number of lines, then one section for each group, the most frequent first, after :data:`~smarter.apps.mcpserver.protocol.operations_tools.UNTRUSTED_DATA_NOTICE`, or a heading that says there were none. :raises SmarterMCPServerToolError: If the query is not valid. :raises LogServiceError: If Loki could not be reached, or rejected the query. :raises InfrastructureNotReadyError: If there is no log store. """ min_level = LogLevel(arguments.min_level) query = log_query( period=timedelta(minutes=arguments.minutes), components=tuple(arguments.components), min_level=min_level, limit=SUMMARY_LINES, ) matching_lines = infrastructure.logs.query(query) # Loki matches the level anywhere in the line; keep the lines whose own level it is. lines = [line for line in matching_lines if at_least(line.level, min_level)] if min_level == LogLevel.ERROR: levels = "errors" else: levels = "warnings and errors" heading = f"## {len(lines)} {levels} in the last {arguments.minutes} minutes" if not lines: return f"{heading}\n\nNone. The logs show no {levels} in this period." if len(lines) >= SUMMARY_LINES: heading += f" (only the latest {SUMMARY_LINES} were grouped)" groups = error_groups(lines) shown_groups = groups[: arguments.groups] out = [UNTRUSTED_DATA_NOTICE + heading, ""] for group in shown_groups: seen = f"{group.first_seen:%H:%M:%S} to {group.last_seen:%H:%M:%S} UTC" if group.level: level = group.level else: level = "unknown" pod_count = len(group.pods) out.append(f"### {group.count} × {level} in {group.component} ({pod_count} pods, {seen})") out.append(f"Signature: `{group.signature}`") out.append("```text") out.append(group.example) out.append("```") out.append("") hidden_groups = len(groups) - arguments.groups if hidden_groups > 0: out.append(f"_{hidden_groups} more groups are not shown._") text = "\n".join(out) return text
__all__ = [ "COMPONENTS", "ListLogSourcesTool", "SearchLogsTool", "SummarizeLogErrorsTool", ]