Skip to content

Commit ac5de56

Browse files
authored
feat(logs): add agentkit logs --harness <name> (#77)
2 parents de43b6a + 0bc2072 commit ac5de56

4 files changed

Lines changed: 662 additions & 0 deletions

File tree

agentkit/toolkit/cli/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from agentkit.toolkit.cli.cli_add import add_app
4949
from agentkit.toolkit.cli.cli_list import list_app
5050
from agentkit.toolkit.cli.cli_delete import delete_app
51+
from agentkit.toolkit.cli.cli_logs import logs_command
5152

5253
# Note: Avoid importing heavy packages at the top to keep CLI startup fast
5354

@@ -110,6 +111,7 @@ def main(
110111
app.command(name="launch")(launch_command)
111112
app.command(name="status")(status_command)
112113
app.command(name="destroy")(destroy_command)
114+
app.command(name="logs")(logs_command)
113115

114116
# Auth: top-level convenience commands + an `auth` group for profiles.
115117
app.command(name="login")(login_command)

agentkit/toolkit/cli/cli_logs.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""AgentKit CLI - ``logs`` command.
16+
17+
Query a deployed harness runtime's logs from APMPlus / TLS. The ``--harness``
18+
value names a runtime; it must carry the harness tag stamped at deploy time
19+
(``agentkit:agenttype=harness``) — otherwise it is a regular agent app whose logs
20+
this command cannot query.
21+
"""
22+
23+
import datetime
24+
import re
25+
import time
26+
from pathlib import Path
27+
from typing import Optional
28+
29+
import typer
30+
from rich.console import Console
31+
32+
console = Console()
33+
34+
_DURATION_UNITS_MS = {"s": 1000, "m": 60_000, "h": 3_600_000, "d": 86_400_000}
35+
_DURATION_RE = re.compile(r"(\d+)([smhd])")
36+
37+
38+
def _parse_since(since: str) -> int:
39+
"""Parse a relative duration like ``1h`` / ``30m`` / ``1h30m`` / ``2d`` to ms.
40+
41+
Raises:
42+
ValueError: when the string has no recognizable ``<number><unit>`` token.
43+
"""
44+
matches = _DURATION_RE.findall(since.strip().lower())
45+
if not matches or "".join(n + u for n, u in matches) != since.strip().lower():
46+
raise ValueError(
47+
f"无法解析 --since '{since}',请使用形如 1h / 30m / 2d / 1h30m 的格式。"
48+
)
49+
return sum(int(n) * _DURATION_UNITS_MS[u] for n, u in matches)
50+
51+
52+
def _format_timestamp(ts) -> str:
53+
"""Format a millisecond epoch timestamp to local time; pass through on failure."""
54+
try:
55+
return datetime.datetime.fromtimestamp(int(ts) / 1000).strftime(
56+
"%Y-%m-%d %H:%M:%S"
57+
)
58+
except (ValueError, TypeError, OSError):
59+
return str(ts)
60+
61+
62+
def _render_line(entry: dict) -> str:
63+
"""Render one log entry as a plain ``<time> [level] <message>`` line."""
64+
ts = _format_timestamp(entry.get("__time__", ""))
65+
# AgentKit runtime logs carry the line in `message`; fall back to the generic
66+
# TLS `__content__` field for other topics.
67+
content = entry.get("message") or entry.get("__content__") or ""
68+
level = entry.get("log_level")
69+
return f"{ts} {level} {content}" if level else f"{ts} {content}"
70+
71+
72+
def _find_runtime(client, target: str):
73+
"""Return the runtime whose name (or RuntimeId) equals ``target``, or None."""
74+
from agentkit.sdk.runtime import types as rt
75+
76+
next_token = None
77+
while True:
78+
resp = client.list_runtimes(
79+
rt.ListRuntimesRequest(max_results=50, next_token=next_token)
80+
)
81+
for runtime in resp.agent_kit_runtimes or []:
82+
if runtime.name == target or runtime.runtime_id == target:
83+
return runtime
84+
next_token = resp.next_token
85+
if not next_token:
86+
return None
87+
88+
89+
def logs_command(
90+
harness: str = typer.Option(
91+
...,
92+
"--harness",
93+
help="Harness runtime name (or RuntimeId) to query logs for.",
94+
),
95+
region: Optional[str] = typer.Option(
96+
None, "--region", help="Region override (default: cn-beijing / global config)."
97+
),
98+
query: Optional[str] = typer.Option(
99+
None,
100+
"--query",
101+
help="Override the TLS query. Default: service:<runtime_id>.<name>.",
102+
),
103+
since: Optional[str] = typer.Option(
104+
None,
105+
"--since",
106+
help="Relative window from now, e.g. 1h / 30m / 2d / 1h30m. Conflicts with --start.",
107+
),
108+
start_time: Optional[int] = typer.Option(
109+
None, "--start", help="Query start time (epoch ms). Default: 15 minutes ago."
110+
),
111+
end_time: Optional[int] = typer.Option(
112+
None, "--end", help="Query end time (epoch ms). Default: now."
113+
),
114+
limit: int = typer.Option(
115+
200, "--limit", help="Max log entries to return (default: 200)."
116+
),
117+
sort: str = typer.Option(
118+
"desc", "--sort", help="Order by time: desc (newest first) or asc."
119+
),
120+
tls_endpoint: Optional[str] = typer.Option(
121+
None, "--tls-endpoint", help="TLS host override (default: tls-<region>.volces.com)."
122+
),
123+
output: Optional[Path] = typer.Option(
124+
None,
125+
"--output",
126+
"-o",
127+
help="Write logs to this file (parent dirs created) instead of stdout.",
128+
),
129+
raw: bool = typer.Option(False, "--raw", help="Print the raw SearchLogs JSON."),
130+
) -> None:
131+
"""Query a deployed harness runtime's logs (APMPlus / TLS).
132+
133+
Examples:
134+
agentkit logs --harness research-agent
135+
agentkit logs --harness my-harness --limit 50 --sort asc
136+
agentkit logs --harness research-agent --since 1h --output ./logs/research-agent.log
137+
"""
138+
import json as _json
139+
140+
if since is not None and start_time is not None:
141+
console.print("[red]❌ --since 与 --start 不能同时使用。[/red]")
142+
raise typer.Exit(1)
143+
144+
from agentkit.platform import VolcConfiguration, resolve_credentials
145+
from agentkit.sdk.runtime.client import AgentkitRuntimeClient
146+
from agentkit.toolkit.harness.deploy import HARNESS_TAG_KEY, HARNESS_TAG_VALUE
147+
from agentkit.toolkit.volcengine import apmplus_logs
148+
149+
cfg = VolcConfiguration(region=region or None)
150+
resolved_region = cfg.region
151+
try:
152+
creds = resolve_credentials("agentkit", platform_config=cfg)
153+
except ValueError as exc:
154+
console.print(f"[red]❌ {exc}[/red]")
155+
raise typer.Exit(1)
156+
157+
# Resolve the runtime and confirm it is a harness before doing anything else.
158+
client = AgentkitRuntimeClient(region=resolved_region)
159+
with console.status("[cyan]Resolving runtime...[/cyan]", spinner="dots"):
160+
runtime = _find_runtime(client, harness)
161+
162+
if runtime is None:
163+
console.print(
164+
f"[red]❌ 未找到名为 '{harness}' 的运行时(region: {resolved_region})。[/red]"
165+
)
166+
raise typer.Exit(1)
167+
168+
is_harness = any(
169+
tag.key == HARNESS_TAG_KEY and tag.value == HARNESS_TAG_VALUE
170+
for tag in (runtime.tags or [])
171+
)
172+
if not is_harness:
173+
console.print("[red]❌ 非 Harness 应用,无法查询日志[/red]")
174+
raise typer.Exit(1)
175+
176+
runtime_id = runtime.runtime_id or ""
177+
runtime_name = runtime.name or harness
178+
final_query = query or f"service:{runtime_id}.{runtime_name}"
179+
180+
now_ms = int(time.time() * 1000)
181+
end_ms = end_time if end_time is not None else now_ms
182+
if since is not None:
183+
try:
184+
start_ms = end_ms - _parse_since(since)
185+
except ValueError as exc:
186+
console.print(f"[red]❌ {exc}[/red]")
187+
raise typer.Exit(1)
188+
elif start_time is not None:
189+
start_ms = start_time
190+
else:
191+
start_ms = end_ms - 15 * 60 * 1000
192+
193+
try:
194+
with console.status("[cyan]Fetching log topic...[/cyan]", spinner="dots"):
195+
topic_id = apmplus_logs.get_log_topic_id(
196+
access_key=creds.access_key,
197+
secret_key=creds.secret_key,
198+
region=resolved_region,
199+
session_token=creds.session_token,
200+
)
201+
with console.status("[cyan]Searching logs...[/cyan]", spinner="dots"):
202+
response = apmplus_logs.search_logs(
203+
access_key=creds.access_key,
204+
secret_key=creds.secret_key,
205+
region=resolved_region,
206+
topic_id=topic_id,
207+
query=final_query,
208+
start_time_ms=start_ms,
209+
end_time_ms=end_ms,
210+
limit=limit,
211+
sort=sort,
212+
session_token=creds.session_token,
213+
tls_host=tls_endpoint,
214+
)
215+
except ValueError as exc:
216+
console.print(f"[red]❌ 查询日志失败: {exc}[/red]")
217+
raise typer.Exit(1)
218+
219+
entries = apmplus_logs.flatten_logs(response)
220+
221+
# Build the text payload once (raw JSON or rendered lines), then either write
222+
# it to --output or print it to the console.
223+
if raw:
224+
payload = _json.dumps(response, ensure_ascii=False, indent=2)
225+
else:
226+
payload = "\n".join(_render_line(entry) for entry in entries)
227+
228+
if output is not None:
229+
output.parent.mkdir(parents=True, exist_ok=True)
230+
output.write_text(payload + ("\n" if payload else ""), encoding="utf-8")
231+
console.print(
232+
f"[green]✅ 命中 {len(entries)} 条日志,已写入 {output}[/green]"
233+
)
234+
return
235+
236+
if raw:
237+
console.print(payload)
238+
return
239+
240+
console.print(f"[blue]Query: {final_query}[/blue]")
241+
if not entries:
242+
console.print("[yellow]未查询到日志。[/yellow]")
243+
return
244+
245+
console.print(f"[green]✅ 命中 {len(entries)} 条日志[/green]")
246+
for entry in entries:
247+
ts = _format_timestamp(entry.get("__time__", ""))
248+
content = entry.get("message") or entry.get("__content__") or ""
249+
level = entry.get("log_level")
250+
prefix = f"[magenta]{ts}[/magenta]"
251+
if level:
252+
prefix += f" [yellow]{level}[/yellow]"
253+
console.print(f"{prefix} {content}")

0 commit comments

Comments
 (0)