Skip to content

Commit 257678a

Browse files
committed
Observability actions for logs: list services, get service logs, clean services logs. Preset fine_logs.
1 parent d0cce17 commit 257678a

19 files changed

Lines changed: 785 additions & 127 deletions

finecode_builtin_handlers/src/finecode_builtin_handlers/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""FineCode Built-in handlers."""
22

3-
from .clean_finecode_logs_handler import CleanFinecodeLogsHandler
3+
from .clean_service_logs_handler import CleanServiceLogsHandler
4+
from .clean_services_logs_discovery_handler import CleanServicesLogsDiscoveryHandler
5+
from .clean_services_logs_iterate_handler import CleanServicesLogsIterateHandler
6+
from .get_service_logs_handler import GetServiceLogsHandler
7+
from .list_observability_services_handler import ListObservabilityServicesHandler
48
from .create_envs_discover_envs_handler import CreateEnvsDiscoverEnvsHandler
59
from .create_envs_dispatch_handler import CreateEnvsDispatchHandler
610
from .dump_config_handler import DumpConfigHandler
@@ -29,7 +33,11 @@
2933
from .uninstall_git_hooks import UninstallGitHooksHandler
3034

3135
__all__ = [
32-
"CleanFinecodeLogsHandler",
36+
"CleanServiceLogsHandler",
37+
"CleanServicesLogsDiscoveryHandler",
38+
"CleanServicesLogsIterateHandler",
39+
"GetServiceLogsHandler",
40+
"ListObservabilityServicesHandler",
3341
"InstallGitHooksHandler",
3442
"LintPrecommitBridgeHandler",
3543
"StagedFilesDiscoveryHandler",

finecode_builtin_handlers/src/finecode_builtin_handlers/clean_finecode_logs_handler.py

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import dataclasses
2+
3+
from finecode_extension_api import code_action
4+
from finecode_extension_api.actions.observability.clean_service_logs_action import (
5+
CleanServiceLogsAction,
6+
CleanServiceLogsRunContext,
7+
CleanServiceLogsRunPayload,
8+
CleanServiceLogsRunResult,
9+
)
10+
from finecode_extension_api.interfaces import (
11+
iextensionrunnerinfoprovider,
12+
ilogger,
13+
)
14+
15+
from finecode_builtin_handlers.observability_log_utils import resolve_log_dir
16+
17+
18+
@dataclasses.dataclass
19+
class CleanServiceLogsHandlerConfig(code_action.ActionHandlerConfig): ...
20+
21+
22+
class CleanServiceLogsHandler(
23+
code_action.ActionHandler[
24+
CleanServiceLogsAction,
25+
CleanServiceLogsHandlerConfig,
26+
]
27+
):
28+
def __init__(
29+
self,
30+
runner_info_provider: iextensionrunnerinfoprovider.IExtensionRunnerInfoProvider,
31+
logger: ilogger.ILogger,
32+
) -> None:
33+
self.runner_info_provider = runner_info_provider
34+
self.logger = logger
35+
36+
async def run(
37+
self,
38+
payload: CleanServiceLogsRunPayload,
39+
run_context: CleanServiceLogsRunContext,
40+
) -> CleanServiceLogsRunResult:
41+
log_dir = resolve_log_dir(payload.service_id, self.runner_info_provider)
42+
if not log_dir.is_dir():
43+
return CleanServiceLogsRunResult()
44+
45+
errors: list[str] = []
46+
for log_file in log_dir.glob("*.log"):
47+
try:
48+
log_file.unlink()
49+
self.logger.info(f"Deleted {log_file}")
50+
except Exception as e:
51+
errors.append(str(e))
52+
53+
return CleanServiceLogsRunResult(errors=errors)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import dataclasses
2+
3+
from finecode_extension_api import code_action
4+
from finecode_extension_api.actions.observability.clean_services_logs_action import (
5+
CleanServicesLogsAction,
6+
CleanServicesLogsRunContext,
7+
CleanServicesLogsRunPayload,
8+
CleanServicesLogsRunResult,
9+
)
10+
from finecode_extension_api.actions.observability.list_observability_services_action import (
11+
ListObservabilityServicesAction,
12+
ListObservabilityServicesRunPayload,
13+
)
14+
from finecode_extension_api.interfaces import ilogger, iprojectactionrunner
15+
16+
17+
@dataclasses.dataclass
18+
class CleanServicesLogsDiscoveryHandlerConfig(code_action.ActionHandlerConfig): ...
19+
20+
21+
class CleanServicesLogsDiscoveryHandler(
22+
code_action.ActionHandler[
23+
CleanServicesLogsAction,
24+
CleanServicesLogsDiscoveryHandlerConfig,
25+
]
26+
):
27+
"""Populate run_context.service_ids by calling list_observability_services.
28+
29+
Must be registered before performing cleaning. No-op when
30+
run_context.service_ids is already set (i.e. explicit caller or repeated run).
31+
"""
32+
33+
def __init__(
34+
self,
35+
project_action_runner: iprojectactionrunner.IProjectActionRunner,
36+
logger: ilogger.ILogger,
37+
) -> None:
38+
self.project_action_runner = project_action_runner
39+
self.logger = logger
40+
41+
async def run(
42+
self,
43+
payload: CleanServicesLogsRunPayload,
44+
run_context: CleanServicesLogsRunContext,
45+
) -> CleanServicesLogsRunResult:
46+
if run_context.service_ids is not None:
47+
return CleanServicesLogsRunResult()
48+
49+
result = await self.project_action_runner.run_action(
50+
action_type=ListObservabilityServicesAction,
51+
payload=ListObservabilityServicesRunPayload(),
52+
meta=run_context.meta,
53+
)
54+
run_context.service_ids = [s.service_id for s in result.services]
55+
self.logger.debug(f"Discovered services to clean: {run_context.service_ids}")
56+
return CleanServicesLogsRunResult()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import dataclasses
2+
3+
from finecode_extension_api import code_action
4+
from finecode_extension_api.actions.observability.clean_service_logs_action import (
5+
CleanServiceLogsAction,
6+
CleanServiceLogsRunPayload,
7+
)
8+
from finecode_extension_api.actions.observability.clean_services_logs_action import (
9+
CleanServicesLogsAction,
10+
CleanServicesLogsRunContext,
11+
CleanServicesLogsRunPayload,
12+
CleanServicesLogsRunResult,
13+
)
14+
from finecode_extension_api.interfaces import ilogger, iprojectactionrunner
15+
16+
17+
@dataclasses.dataclass
18+
class CleanServicesLogsIterateHandlerConfig(code_action.ActionHandlerConfig): ...
19+
20+
21+
class CleanServicesLogsIterateHandler(
22+
code_action.ActionHandler[
23+
CleanServicesLogsAction,
24+
CleanServicesLogsIterateHandlerConfig,
25+
]
26+
):
27+
"""Call clean_service_logs for each service_id in run_context.service_ids.
28+
29+
Must be registered after discovery handler.
30+
"""
31+
32+
def __init__(
33+
self,
34+
project_action_runner: iprojectactionrunner.IProjectActionRunner,
35+
logger: ilogger.ILogger,
36+
) -> None:
37+
self.project_action_runner = project_action_runner
38+
self.logger = logger
39+
40+
async def run(
41+
self,
42+
payload: CleanServicesLogsRunPayload,
43+
run_context: CleanServicesLogsRunContext,
44+
) -> CleanServicesLogsRunResult:
45+
if run_context.service_ids is None:
46+
raise code_action.ActionFailedException(
47+
"CleanServicesLogsDiscoveryHandler must be registered before"
48+
" CleanServicesLogsIterateHandler"
49+
)
50+
if not run_context.service_ids:
51+
return CleanServicesLogsRunResult()
52+
53+
services_cleaned: list[str] = []
54+
errors: list[str] = []
55+
56+
for service_id in run_context.service_ids:
57+
result = await self.project_action_runner.run_action(
58+
action_type=CleanServiceLogsAction,
59+
payload=CleanServiceLogsRunPayload(service_id=service_id),
60+
meta=run_context.meta,
61+
)
62+
if result.errors:
63+
errors.extend(result.errors)
64+
else:
65+
services_cleaned.append(service_id)
66+
67+
return CleanServicesLogsRunResult(
68+
services_cleaned=services_cleaned,
69+
errors=errors,
70+
)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import dataclasses
2+
import pathlib
3+
import re
4+
from datetime import datetime
5+
6+
from finecode_extension_api import code_action
7+
from finecode_extension_api.actions.observability.get_service_logs_action import (
8+
GetServiceLogsAction,
9+
GetServiceLogsRunContext,
10+
GetServiceLogsRunPayload,
11+
GetServiceLogsRunResult,
12+
)
13+
from finecode_extension_api.interfaces import (
14+
iextensionrunnerinfoprovider,
15+
ilogger,
16+
)
17+
18+
from finecode_builtin_handlers.observability_log_utils import resolve_log_dir
19+
20+
_TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}[\sT]\d{2}:\d{2}:\d{2})")
21+
22+
23+
def _log_file_sort_key(f: pathlib.Path) -> int:
24+
"""Sort log files by their numeric rotation ID (e.g. 'runner_1.log' → 1)."""
25+
stem = f.stem
26+
parts = stem.rsplit("_", 1)
27+
if len(parts) == 2 and parts[1].isdigit():
28+
return int(parts[1])
29+
return 0
30+
31+
32+
def _read_log_lines(log_dir: pathlib.Path) -> list[str]:
33+
files = sorted(log_dir.glob("*.log"), key=_log_file_sort_key)
34+
lines: list[str] = []
35+
for f in files:
36+
try:
37+
lines.extend(f.read_text(encoding="utf-8", errors="replace").splitlines())
38+
except OSError:
39+
pass
40+
return lines
41+
42+
43+
def _parse_line_ts(line: str) -> datetime | None:
44+
m = _TS_RE.match(line)
45+
if not m:
46+
return None
47+
try:
48+
return datetime.fromisoformat(m.group(1).replace(" ", "T"))
49+
except ValueError:
50+
return None
51+
52+
53+
def _filter_since(lines: list[str], since_ts_iso: str) -> list[str]:
54+
"""Best-effort: keep lines at or after since_ts_iso. Lines without a parseable
55+
timestamp are always kept (they may be continuation lines of a log entry)."""
56+
try:
57+
since_dt = datetime.fromisoformat(since_ts_iso.replace("Z", "+00:00"))
58+
# Strip timezone for naive comparison — log timestamps are local time
59+
since_dt = since_dt.replace(tzinfo=None)
60+
except ValueError:
61+
return lines
62+
63+
result: list[str] = []
64+
for line in lines:
65+
ts = _parse_line_ts(line)
66+
if ts is None or ts >= since_dt:
67+
result.append(line)
68+
return result
69+
70+
71+
@dataclasses.dataclass
72+
class GetServiceLogsHandlerConfig(code_action.ActionHandlerConfig): ...
73+
74+
75+
class GetServiceLogsHandler(
76+
code_action.ActionHandler[
77+
GetServiceLogsAction,
78+
GetServiceLogsHandlerConfig,
79+
]
80+
):
81+
def __init__(
82+
self,
83+
runner_info_provider: iextensionrunnerinfoprovider.IExtensionRunnerInfoProvider,
84+
logger: ilogger.ILogger,
85+
) -> None:
86+
self.runner_info_provider = runner_info_provider
87+
self.logger = logger
88+
89+
async def run(
90+
self,
91+
payload: GetServiceLogsRunPayload,
92+
run_context: GetServiceLogsRunContext,
93+
) -> GetServiceLogsRunResult:
94+
log_dir = resolve_log_dir(payload.service_id, self.runner_info_provider)
95+
if not log_dir.is_dir():
96+
return GetServiceLogsRunResult(
97+
service_id=payload.service_id,
98+
errors=[f"No logs directory found for service '{payload.service_id}'."],
99+
)
100+
101+
lines = _read_log_lines(log_dir)
102+
103+
if payload.since_ts_iso is not None:
104+
lines = _filter_since(lines, payload.since_ts_iso)
105+
106+
total = len(lines)
107+
108+
# Pagination: select a window of lines counting back from the most recent end.
109+
# offset_lines skips the last N lines before applying the tail window.
110+
end = max(0, total - payload.offset_lines)
111+
if payload.tail_lines is not None:
112+
start = end - payload.tail_lines
113+
truncated = start > 0
114+
start = max(0, start)
115+
else:
116+
start = 0
117+
truncated = False
118+
119+
return GetServiceLogsRunResult(
120+
service_id=payload.service_id,
121+
content="\n".join(lines[start:end]),
122+
truncated=truncated,
123+
)

0 commit comments

Comments
 (0)