Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Tests

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: python -m pip install '.[dev]' build
- run: PYTHONPATH=src python -m pytest
- run: python -m build
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ baton taint clear # remove all canary data
### Service Logs

```bash
baton logs [--node N] [--level L] # show captured service logs
baton logs [-f | --follow] [--node N] [--level L] # show or follow captured service logs
```

### Arbiter & Constrain
Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ <h2>Five minutes to a running circuit</h2>
<span class="cmd">baton test</span> <span class="flag">--canary</span> <span class="flag">--tiers</span> <span class="arg">PII</span> <span class="flag">--duration</span> <span class="arg">5m</span>

<span class="comment"># Service logs and Arbiter trust</span>
<span class="cmd">baton logs</span> <span class="flag">--node</span> <span class="arg">api</span> <span class="flag">--level</span> <span class="arg">error</span>
<span class="cmd">baton logs</span> <span class="flag">--follow</span> <span class="flag">--node</span> <span class="arg">api</span> <span class="flag">--level</span> <span class="arg">error</span>
<span class="cmd">baton trust</span> <span class="arg">api</span>

<span class="comment"># Build images and deploy to GCP Cloud Run</span>
Expand Down Expand Up @@ -764,7 +764,7 @@ <h3 style="font-size: 16px; font-weight: 600; margin: 24px 0 12px; color: var(--
<span class="cmd">baton metrics</span> <span class="flag">--prometheus</span> <span class="comment"># Prometheus text exposition format</span>
<span class="cmd">baton signals</span> <span class="flag">[--node N] [--path P]</span> <span class="comment"># recent request signals</span>
<span class="cmd">baton signals</span> <span class="flag">--stats</span> <span class="comment"># per-path statistics</span>
<span class="cmd">baton logs</span> <span class="flag">[--node N] [--level L]</span> <span class="comment"># captured service logs</span>
<span class="cmd">baton logs</span> <span class="flag">[-f | --follow] [--node N] [--level L]</span> <span class="comment"># show or follow captured service logs</span>
</div>

<h3 style="font-size: 16px; font-weight: 600; margin: 24px 0 12px; color: var(--accent);">Taint Analysis</h3>
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies = [
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"mcp[cli]>=1.26.0",
"mcp[cli]>=1.26.0,<2",
]
gcp = [
"google-cloud-run>=0.10",
Expand All @@ -36,7 +36,7 @@ aws = [
]
otel = []
mcp = [
"mcp[cli]>=1.26.0",
"mcp[cli]>=1.26.0,<2",
]
certs = [
"cryptography>=41.0",
Expand Down
31 changes: 24 additions & 7 deletions src/baton/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ def main(argv: list[str] | None = None) -> int:
p_logs.add_argument("--node", default="", help="Filter by node")
p_logs.add_argument("--level", default="", help="Minimum severity level (debug/info/warning/error/critical)")
p_logs.add_argument("--last", type=int, default=50, help="Number of entries (default: 50)")
p_logs.add_argument("-f", "--follow", action="store_true", help="Follow new log entries")
p_logs.add_argument("--dir", default=".", help="Project directory")
p_logs.set_defaults(func=_cmd_logs)

Expand Down Expand Up @@ -1514,6 +1515,17 @@ def _cmd_dora(args: argparse.Namespace) -> int:
def _cmd_logs(args: argparse.Namespace) -> int:
from baton.service_log import ServiceLogCollector

if getattr(args, "follow", False):
records = ServiceLogCollector.follow_history(
args.dir,
node=args.node or None,
severity=args.level or None,
last_n=args.last,
)
for record in records:
_print_service_log(record)
return 0

records = ServiceLogCollector.load_history(
args.dir,
node=args.node or None,
Expand All @@ -1524,17 +1536,22 @@ def _cmd_logs(args: argparse.Namespace) -> int:
print("No service logs found")
return 0

for r in records:
sev = r.get("severity", "info").upper()
node = r.get("node_name", "?")
ts = r.get("timestamp", "")[:19] # Trim to seconds
stream = r.get("stream", "")
msg = r.get("message", "")
print(f"[{ts}] [{sev:<8}] [{node}:{stream}] {msg}")
for record in records:
_print_service_log(record)

return 0


def _print_service_log(record: dict) -> None:
"""Print one structured service log record."""
sev = record.get("severity", "info").upper()
node = record.get("node_name", "?")
ts = record.get("timestamp", "")[:19] # Trim to seconds
stream = record.get("stream", "")
msg = record.get("message", "")
print(f"[{ts}] [{sev:<8}] [{node}:{stream}] {msg}", flush=True)


async def _cmd_dashboard(args: argparse.Namespace) -> int:
import json as json_mod
from baton.dashboard import collect, format_table
Expand Down
80 changes: 70 additions & 10 deletions src/baton/service_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@

from __future__ import annotations

import json
import re
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path

from baton.state import append_jsonl, read_jsonl
from baton.state import BATON_DIR, append_jsonl, read_jsonl

LOGS_FILE = "service_logs.jsonl"

Expand Down Expand Up @@ -40,6 +43,20 @@ def parse_severity(line: str) -> str:
return "" # caller decides default based on stream


def _matches_filters(
record: dict,
node: str | None = None,
severity: str | None = None,
) -> bool:
"""Return whether a structured log record matches CLI filters."""
if node and record.get("node_name") != node:
return False
if severity and severity in SEVERITIES:
minimum = SEVERITIES.index(severity)
return SEVERITIES.index(record.get("severity", "info")) >= minimum
return True


class ServiceLogCollector:
"""Captures and structures service log output.

Expand Down Expand Up @@ -113,12 +130,55 @@ def load_history(
) -> list[dict]:
"""Read from .baton/service_logs.jsonl."""
records = read_jsonl(project_dir, LOGS_FILE, last_n=last_n)
if node:
records = [r for r in records if r.get("node_name") == node]
if severity and severity in SEVERITIES:
sev_idx = SEVERITIES.index(severity)
records = [
r for r in records
if SEVERITIES.index(r.get("severity", "info")) >= sev_idx
]
return records
return [
record for record in records
if _matches_filters(record, node=node, severity=severity)
]

@staticmethod
def follow_history(
project_dir: str | Path,
node: str | None = None,
severity: str | None = None,
last_n: int | None = 50,
poll_interval: float = 0.2,
) -> Iterator[dict]:
"""Yield recent records, then wait for and yield newly appended records."""
path = Path(project_dir) / BATON_DIR / LOGS_FILE
while not path.exists():
time.sleep(poll_interval)

with open(path) as log_file:
history: list[str] = []
while True:
offset = log_file.tell()
line = log_file.readline()
if not line:
break
if not line.endswith("\n"):
log_file.seek(offset)
break
history.append(line)

if last_n is not None:
history = history[-last_n:]
for line in history:
if not line.strip():
continue
record = json.loads(line)
if _matches_filters(record, node=node, severity=severity):
yield record

while True:
offset = log_file.tell()
line = log_file.readline()
if not line or not line.endswith("\n"):
log_file.seek(offset)
time.sleep(poll_interval)
continue

if not line.strip():
continue
record = json.loads(line)
if _matches_filters(record, node=node, severity=severity):
yield record
51 changes: 51 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,57 @@ def test_signals_path_filter(self, project_dir: Path, capsys):
assert "/users" not in out


class TestLogsCLI:
@pytest.mark.parametrize("follow_flag", ["-f", "--follow"])
def test_follow_streams_records_with_filters(
self,
project_dir: Path,
monkeypatch,
capsys,
follow_flag: str,
):
from baton.service_log import ServiceLogCollector

captured = {}

def follow_history(project_dir, *, node, severity, last_n):
captured.update(
project_dir=project_dir,
node=node,
severity=severity,
last_n=last_n,
)
return iter([
{
"node_name": "api",
"stream": "stderr",
"severity": "error",
"message": "request failed",
"timestamp": "2026-01-01T00:00:00+00:00",
}
])

monkeypatch.setattr(ServiceLogCollector, "follow_history", follow_history)

rc = main([
"logs",
follow_flag,
"--node", "api",
"--level", "warning",
"--last", "7",
"--dir", str(project_dir),
])

assert rc == 0
assert captured == {
"project_dir": str(project_dir),
"node": "api",
"severity": "warning",
"last_n": 7,
}
assert "[ERROR ] [api:stderr] request failed" in capsys.readouterr().out


class TestMetricsCLI:
def _init_with_metrics(self, d: Path) -> Path:
"""Initialize a project and write telemetry data."""
Expand Down
Loading