Skip to content

Commit 28bbc01

Browse files
authored
Merge pull request #2113 from leggedrobotics/feat/improve-action-log-following
Feat/improve action log following
2 parents 81382db + 5f32f9d commit 28bbc01

9 files changed

Lines changed: 244 additions & 64 deletions

File tree

backend/src/services/action.service.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export class ActionService {
207207
(action.executionEndedAt?.getTime() ?? Date.now()) * 1_000_000 +
208208
60_000_000_000;
209209

210-
let logQl = `{job="queue-consumer", action_run_uuid="${actionUuid}"}`;
210+
let logQl = `{job="queue-consumer"} | json action_run_uuid="labels.action_run_uuid" | action_run_uuid="${actionUuid}"`;
211211
if (query.search) {
212212
logQl += ` |= "${query.search}"`;
213213
}
@@ -285,7 +285,6 @@ export class ActionService {
285285
message: string;
286286
type?: LogType;
287287
};
288-
289288
return {
290289
timestamp:
291290
validParsed.timestamp ??
@@ -294,6 +293,7 @@ export class ActionService {
294293
).toISOString(),
295294
message: validParsed.message,
296295
type: validParsed.type ?? ('stdout' as LogType),
296+
_tsNs: tsNs,
297297
};
298298
} catch {
299299
return {
@@ -305,17 +305,43 @@ export class ActionService {
305305
? lineJson
306306
: JSON.stringify(lineJson),
307307
type: 'stdout' as LogType,
308+
_tsNs: tsNs,
308309
};
309310
}
310311
}),
311312
);
312313

313-
// Sort by timestamp (Loki might return multiple streams interleaved)
314-
allLogs.sort(
315-
(a, b) =>
316-
new Date(a.timestamp).getTime() -
317-
new Date(b.timestamp).getTime(),
318-
);
314+
// First strictly compare exact ISO strings (Docker log timestamp has nanosec encoding format).
315+
// Then fallback to tsNs Loki timestamps.
316+
allLogs.sort((a, b) => {
317+
const cmp = a.timestamp.localeCompare(b.timestamp);
318+
if (cmp !== 0) {
319+
return cmp;
320+
}
321+
322+
const tsNsA =
323+
'_tsNs' in a && typeof a._tsNs === 'string'
324+
? BigInt(a._tsNs)
325+
: BigInt(new Date(a.timestamp).getTime()) * 1_000_000n;
326+
const tsNsB =
327+
'_tsNs' in b && typeof b._tsNs === 'string'
328+
? BigInt(b._tsNs)
329+
: BigInt(new Date(b.timestamp).getTime()) * 1_000_000n;
330+
331+
if (tsNsA < tsNsB) {
332+
return -1;
333+
}
334+
if (tsNsA > tsNsB) {
335+
return 1;
336+
}
337+
return 0;
338+
});
339+
340+
for (const log of allLogs) {
341+
if ('_tsNs' in log) {
342+
delete (log as Record<string, unknown>)._tsNs;
343+
}
344+
}
319345

320346
const count = allLogs.length;
321347
const data = allLogs.slice(query.skip, query.skip + query.take);

cli/kleinkram/api/routes.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,16 @@ def get_run(
202202
if resp.status_code == 404:
203203
raise kleinkram.errors.RunNotFound(f"Run not found: {run_id}")
204204
resp.raise_for_status()
205-
return _parse_run(RunObject(resp.json()))
205+
run_object = resp.json()
206+
207+
try:
208+
logs_resp = client.get(f"{ACTION_ENDPOINT}s/{run_id}/logs")
209+
if logs_resp.status_code == 200:
210+
run_object["logs"] = logs_resp.json().get("data", [])
211+
except Exception:
212+
pass
213+
214+
return _parse_run(RunObject(run_object))
206215

207216

208217
def get_action_templates(

cli/kleinkram/cli/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def check_version_compatibility() -> None:
219219

220220
@app.callback()
221221
def cli(
222-
verbose: bool = typer.Option(True, help="Enable verbose mode."),
222+
verbose: bool = typer.Option(False, help="Enable verbose mode."),
223223
debug: bool = typer.Option(False, help="Enable debug mode."),
224224
version: Optional[bool] = typer.Option(None, "--version", "-v", callback=_version_callback),
225225
log_level: Optional[LogLevel] = typer.Option(None, help="Set log level."),

cli/kleinkram/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ def endpoint_table(config: Config) -> Table:
245245
@dataclass
246246
class SharedState:
247247
log_file: Optional[Path] = None
248-
verbose: bool = True
248+
verbose: bool = False
249249
debug: bool = False
250250
max_table_size: int = MAX_TABLE_SIZE
251251

cli/kleinkram/printing.py

Lines changed: 125 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import time
66
from dataclasses import asdict
77
from datetime import datetime
8+
from datetime import timezone
89
from pathlib import Path
910
from typing import List
1011
from typing import Mapping
@@ -17,6 +18,10 @@
1718
import httpx
1819
import typer
1920
from rich.console import Console
21+
from rich.console import Group
22+
from rich.live import Live
23+
from rich.panel import Panel
24+
from rich.spinner import Spinner
2025
from rich.table import Table
2126
from rich.text import Text
2227

@@ -539,6 +544,71 @@ def print_action_templates_table(templates: Sequence[ActionTemplate], *, pprint:
539544
print(template.uuid)
540545

541546

547+
def generate_live_layout(run_details: Run) -> Group:
548+
verbose = get_shared_state().verbose
549+
550+
# Calculate elapsed time
551+
if run_details.updated_at:
552+
elapsed = run_details.updated_at - run_details.created_at
553+
else:
554+
elapsed = datetime.now(timezone.utc) - run_details.created_at
555+
556+
elapsed_seconds = int(elapsed.total_seconds())
557+
hours, remainder = divmod(elapsed_seconds, 3600)
558+
minutes, seconds = divmod(remainder, 60)
559+
elapsed_str = f"{hours}:{minutes:02}:{seconds:02}"
560+
561+
state_upper = run_details.state.upper()
562+
563+
if state_upper == "DONE":
564+
status_color = "green"
565+
icon = Text("[✔]", style="green")
566+
state_text = "DONE"
567+
elif state_upper in {"FAILED", "UNPROCESSABLE"}:
568+
status_color = "red"
569+
icon = Text("[✘]", style="red")
570+
state_text = f"{run_details.state}"
571+
else:
572+
status_color = "yellow"
573+
icon = Spinner("dots", style="blue")
574+
state_text = f"{run_details.state}"
575+
576+
header_text = Text()
577+
header_text.append(f"Running Action {run_details.uuid} ", style="bold")
578+
header_text.append(f"{elapsed_str} ", style="dim")
579+
header_text.append(f"({state_text})", style=status_color)
580+
581+
header_table = Table.grid(padding=(0, 1))
582+
header_table.add_column()
583+
header_table.add_column()
584+
header_table.add_row(icon, header_text)
585+
586+
logs_text = Text()
587+
last_logs = run_details.logs[-20:]
588+
if not last_logs:
589+
logs_text.append(" => ", style="blue")
590+
if state_upper in {"DONE", "FAILED", "UNPROCESSABLE"}:
591+
logs_text.append("No logs produced.", style="dim")
592+
else:
593+
logs_text.append("Waiting for logs...", style="dim")
594+
else:
595+
for i, log in enumerate(last_logs):
596+
logs_text.append(" => ", style="blue")
597+
598+
if verbose:
599+
level = log.level.upper().strip()
600+
color = LOG_LEVEL_COLORS.get(level, "white")
601+
timestamp_str = log.timestamp.strftime("%Y-%m-%d %H:%M:%S")
602+
level_str = f"[{level.ljust(8)}]"
603+
logs_text.append(f"[{timestamp_str}] {level_str} ", style=str(color))
604+
605+
logs_text.append(log.message.strip())
606+
if i < len(last_logs) - 1:
607+
logs_text.append("\n")
608+
609+
return Group(header_table, logs_text)
610+
611+
542612
def follow_run_logs(client: AuthenticatedClient, run_uuid: str) -> int:
543613
"""
544614
Polls the API for run details and prints new logs as they arrive.
@@ -549,40 +619,67 @@ def follow_run_logs(client: AuthenticatedClient, run_uuid: str) -> int:
549619
typer.echo(f"Following logs for run {run_uuid}...")
550620

551621
TERMINAL_STATES = {"DONE", "FAILED", "UNPROCESSABLE"}
552-
printed_log_count = 0
553622
current_run_state = None
554623
exit_code = 0 # Assume success
555624

556625
try:
557-
while current_run_state not in TERMINAL_STATES:
558-
try:
559-
run_details: Run = kleinkram.api.routes.get_run(client, run_uuid)
560-
current_run_state = run_details.state.upper()
561-
562-
# Print only new logs
563-
new_logs = run_details.logs[printed_log_count:]
564-
if new_logs:
565-
# Always pretty-print when following
566-
print_run_logs(new_logs, pprint=True)
567-
printed_log_count = len(run_details.logs)
568-
569-
if current_run_state in TERMINAL_STATES:
570-
color = typer.colors.GREEN if run_details.state.upper() == "DONE" else typer.colors.RED
571-
typer.secho(
572-
f"\nRun finished with state: {run_details.state} ({run_details.state_cause})",
573-
fg=color,
574-
)
575-
if run_details.state.upper() != "DONE":
576-
exit_code = 1 # Set failure exit code
626+
with Live(refresh_per_second=10) as live:
627+
while current_run_state not in TERMINAL_STATES:
628+
try:
629+
run_details: Run = kleinkram.api.routes.get_run(client, run_uuid)
630+
current_run_state = run_details.state.upper()
631+
632+
live.update(generate_live_layout(run_details), refresh=True)
633+
634+
if current_run_state in TERMINAL_STATES:
635+
if current_run_state != "DONE":
636+
exit_code = 1 # Set failure exit code
637+
break
638+
639+
time.sleep(1) # Poll every 1 seconds for a more responsive timer
640+
641+
except kleinkram.errors.RunNotFound:
642+
time.sleep(1)
643+
except httpx.HTTPStatusError:
644+
time.sleep(3) # Wait longer on API errors
645+
except httpx.RequestError as e:
646+
# Catch raw socket errors (like Connection reset by peer)
647+
err_msg = Text(f"\nConnection lost. Please check your network and try again. ({e})", style="red")
648+
live.console.print(err_msg)
649+
time.sleep(3)
650+
651+
# After the Live block finishes, print the final state clearly
652+
try:
653+
run_details: Run = kleinkram.api.routes.get_run(client, run_uuid)
654+
state_upper = run_details.state.upper()
655+
656+
has_warnings = False
657+
for log in run_details.logs:
658+
if "CORRUPTED" in log.message.upper() or log.level.upper() in {"WARN", "WARNING", "ERROR"}:
659+
has_warnings = True
577660
break
578661

579-
time.sleep(2) # Poll every 2 seconds
580-
581-
except kleinkram.errors.RunNotFound:
582-
time.sleep(1)
583-
except httpx.HTTPStatusError as e:
584-
typer.secho(f"Error fetching run status: {e}", fg=typer.colors.RED)
585-
time.sleep(5) # Wait longer on API errors
662+
if state_upper == "DONE" and has_warnings:
663+
color = typer.colors.YELLOW
664+
state_display = "DONE (Completed with Warnings)"
665+
elif state_upper == "DONE":
666+
color = typer.colors.GREEN
667+
state_display = "DONE"
668+
else:
669+
color = typer.colors.RED
670+
state_display = run_details.state
671+
672+
state_cause_str = f" ({run_details.state_cause})" if run_details.state_cause else ""
673+
typer.secho(
674+
f"\nRun finished with state: {state_display}{state_cause_str}",
675+
fg=color,
676+
)
677+
except httpx.HTTPStatusError as e:
678+
typer.secho(f"\nFailed to fetch final run details (API error): {e}", fg=typer.colors.RED)
679+
except httpx.RequestError as e:
680+
typer.secho(f"\nFailed to fetch final run details (network error): {e}", fg=typer.colors.RED)
681+
except Exception as e:
682+
typer.secho(f"\nFailed to fetch final run details: {e}", fg=typer.colors.RED)
586683

587684
except KeyboardInterrupt:
588685
typer.secho(

cli/setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ warn_unused_configs = True
3838
exclude = (setup.py|build/|tests/)
3939

4040
[tool:pytest]
41+
norecursedirs = test-venv .venv venv build dist
4142
python_files = tests/*.py tests/**/*.py tests.py test_*.py *_tests.py
4243
markers =
4344
slow: marks tests as slow (deselect with '-m "not slow"')

observability/loki/loki-config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ limits_config:
4545
max_entries_limit_per_query: 50000
4646
retention_period: 720h # Default 30 days
4747
retention_stream:
48-
- selector: '{action_run_uuid=~".+"}'
48+
- selector: '{job="queue-consumer"}'
4949
priority: 1
5050
period: 2160h # 90 days for action logs
5151

5252
compactor:
5353
working_directory: /tmp/loki/boltdb-shipper-compactor
54-
shared_store: filesystem
5554
retention_enabled: true
55+
delete_request_store: filesystem

packages/backend-common/src/modules/action-dispatcher/action-dispatcher.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,17 @@ export class ActionDispatcherService implements OnModuleInit {
107107
);
108108
}
109109

110+
try {
111+
const lokiUrl = process.env.LOKI_URL ?? 'http://loki:3100';
112+
const { default: axios } = await import('axios');
113+
await axios.get(`${lokiUrl}/ready`, { timeout: 2000 });
114+
} catch {
115+
this.logger.error('Loki logging system is down or unreachable');
116+
throw new ConflictException(
117+
'Logging system (Loki) is not available. Please try again later.',
118+
);
119+
}
120+
110121
let action = this.actionRepository.create({
111122
mission,
112123
creator,

0 commit comments

Comments
 (0)