instruktai-python-logger 0.1.5__tar.gz → 0.3.12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- instruktai_python_logger-0.3.12/PKG-INFO +111 -0
- instruktai_python_logger-0.3.12/README.md +101 -0
- instruktai_python_logger-0.3.12/instrukt_ai_logging/__init__.py +32 -0
- instruktai_python_logger-0.3.12/instrukt_ai_logging/cli.py +217 -0
- instruktai_python_logger-0.3.12/instrukt_ai_logging/install.py +59 -0
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/instrukt_ai_logging/logging.py +168 -40
- instruktai_python_logger-0.3.12/instrukt_ai_logging/py.typed +1 -0
- instruktai_python_logger-0.3.12/instruktai_python_logger.egg-info/PKG-INFO +111 -0
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/instruktai_python_logger.egg-info/SOURCES.txt +6 -1
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/instruktai_python_logger.egg-info/entry_points.txt +1 -0
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/pyproject.toml +6 -2
- instruktai_python_logger-0.3.12/tests/test_cli_follow.py +46 -0
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/tests/test_configure_logging.py +44 -1
- instruktai_python_logger-0.3.12/tests/test_log_aggregation.py +132 -0
- instruktai_python_logger-0.3.12/tests/test_trace_logging.py +69 -0
- instruktai_python_logger-0.1.5/PKG-INFO +0 -69
- instruktai_python_logger-0.1.5/README.md +0 -59
- instruktai_python_logger-0.1.5/instrukt_ai_logging/__init__.py +0 -19
- instruktai_python_logger-0.1.5/instrukt_ai_logging/cli.py +0 -52
- instruktai_python_logger-0.1.5/instruktai_python_logger.egg-info/PKG-INFO +0 -69
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/instruktai_python_logger.egg-info/requires.txt +0 -0
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/instruktai_python_logger.egg-info/top_level.txt +0 -0
- {instruktai_python_logger-0.1.5 → instruktai_python_logger-0.3.12}/setup.cfg +0 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: instruktai-python-logger
|
|
3
|
+
Version: 0.3.12
|
|
4
|
+
Summary: Centralized logging utilities for Python services.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
9
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# InstruktAI Python Logger
|
|
12
|
+
|
|
13
|
+
Centralized logging utilities for Python services.
|
|
14
|
+
|
|
15
|
+
This repo provides a shared, consistent logging contract (env vars + output format + log location) intended to keep logs highly queryable (including by AIs that only read a tail window).
|
|
16
|
+
|
|
17
|
+
Background and rationale live in `docs/design.md`.
|
|
18
|
+
Publishing notes live in `docs/publishing.md`.
|
|
19
|
+
|
|
20
|
+
## Install (editable, local workspace)
|
|
21
|
+
|
|
22
|
+
From PyPI (recommended):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install instrukt-ai-logger
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
From GitHub:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install git+ssh://git@github.com/InstruktAI/python-logger.git
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## API
|
|
35
|
+
|
|
36
|
+
- Python entrypoint: `instrukt_ai_logging.configure_logging(...)`
|
|
37
|
+
- Logger helper: `instrukt_ai_logging.get_logger(name)` (named `**kv` logging)
|
|
38
|
+
- CLI entrypoint: `instrukt-ai-logs` (reads recent log lines)
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
|
|
42
|
+
```py
|
|
43
|
+
import logging
|
|
44
|
+
from instrukt_ai_logging import configure_logging
|
|
45
|
+
|
|
46
|
+
configure_logging("teleclaude")
|
|
47
|
+
logger = logging.getLogger("teleclaude.core")
|
|
48
|
+
logger.info("job_started", job_id="abc123", user_id=123)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Per-process log files (multi-producer apps)
|
|
52
|
+
|
|
53
|
+
When several processes belong to the same app (a daemon plus a watcher plus a
|
|
54
|
+
cron, for example), each one can own its own file under the same app directory
|
|
55
|
+
by passing `source=`:
|
|
56
|
+
|
|
57
|
+
```py
|
|
58
|
+
configure_logging("teleclaude") # → teleclaude.log (default)
|
|
59
|
+
configure_logging("teleclaude", source="docs-watch") # → docs-watch.log
|
|
60
|
+
configure_logging("teleclaude", source="cron") # → cron.log
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
64
|
+
under `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
|
|
65
|
+
(see below).
|
|
66
|
+
|
|
67
|
+
## Environment variables (contract)
|
|
68
|
+
|
|
69
|
+
Per-app prefix model (example uses `TELECLAUDE_`):
|
|
70
|
+
|
|
71
|
+
- `TELECLAUDE_LOG_LEVEL`
|
|
72
|
+
- `TELECLAUDE_THIRD_PARTY_LOG_LEVEL`
|
|
73
|
+
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
74
|
+
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
75
|
+
|
|
76
|
+
Global:
|
|
77
|
+
|
|
78
|
+
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
79
|
+
|
|
80
|
+
## Log location (contract)
|
|
81
|
+
|
|
82
|
+
Default target:
|
|
83
|
+
|
|
84
|
+
- `/var/log/instrukt-ai/{app}/{app}.log`
|
|
85
|
+
|
|
86
|
+
The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
|
|
87
|
+
|
|
88
|
+
## CLI
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Last 10 minutes (default), follows
|
|
92
|
+
instrukt-ai-logs teleclaude -f
|
|
93
|
+
|
|
94
|
+
# Last 2 hours
|
|
95
|
+
instrukt-ai-logs teleclaude --since 2h
|
|
96
|
+
|
|
97
|
+
# Filter with regex
|
|
98
|
+
instrukt-ai-logs teleclaude --since 10m --grep 'logger=teleclaude'
|
|
99
|
+
|
|
100
|
+
# Follow (tail -f style) after printing the --since window
|
|
101
|
+
instrukt-ai-logs teleclaude --since 10m --follow
|
|
102
|
+
|
|
103
|
+
# Restrict to specific source streams (per-process files)
|
|
104
|
+
instrukt-ai-logs teleclaude --since 1h --logs=cron,docs-watch
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Without `--logs`, every `*.log*` file in the app directory is merged by
|
|
108
|
+
timestamp (rotation suffixes included). With `--logs=stem1,stem2`, only files
|
|
109
|
+
whose stem matches an entry are read — exact stem match, so `--logs=cron`
|
|
110
|
+
picks `cron.log`, `cron.log.0`, `cron.log.1` and never `cron-backup.log`.
|
|
111
|
+
Follow mode reads each selected file with one polling thread per file.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# InstruktAI Python Logger
|
|
2
|
+
|
|
3
|
+
Centralized logging utilities for Python services.
|
|
4
|
+
|
|
5
|
+
This repo provides a shared, consistent logging contract (env vars + output format + log location) intended to keep logs highly queryable (including by AIs that only read a tail window).
|
|
6
|
+
|
|
7
|
+
Background and rationale live in `docs/design.md`.
|
|
8
|
+
Publishing notes live in `docs/publishing.md`.
|
|
9
|
+
|
|
10
|
+
## Install (editable, local workspace)
|
|
11
|
+
|
|
12
|
+
From PyPI (recommended):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install instrukt-ai-logger
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
From GitHub:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install git+ssh://git@github.com/InstruktAI/python-logger.git
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## API
|
|
25
|
+
|
|
26
|
+
- Python entrypoint: `instrukt_ai_logging.configure_logging(...)`
|
|
27
|
+
- Logger helper: `instrukt_ai_logging.get_logger(name)` (named `**kv` logging)
|
|
28
|
+
- CLI entrypoint: `instrukt-ai-logs` (reads recent log lines)
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
|
|
32
|
+
```py
|
|
33
|
+
import logging
|
|
34
|
+
from instrukt_ai_logging import configure_logging
|
|
35
|
+
|
|
36
|
+
configure_logging("teleclaude")
|
|
37
|
+
logger = logging.getLogger("teleclaude.core")
|
|
38
|
+
logger.info("job_started", job_id="abc123", user_id=123)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Per-process log files (multi-producer apps)
|
|
42
|
+
|
|
43
|
+
When several processes belong to the same app (a daemon plus a watcher plus a
|
|
44
|
+
cron, for example), each one can own its own file under the same app directory
|
|
45
|
+
by passing `source=`:
|
|
46
|
+
|
|
47
|
+
```py
|
|
48
|
+
configure_logging("teleclaude") # → teleclaude.log (default)
|
|
49
|
+
configure_logging("teleclaude", source="docs-watch") # → docs-watch.log
|
|
50
|
+
configure_logging("teleclaude", source="cron") # → cron.log
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
54
|
+
under `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
|
|
55
|
+
(see below).
|
|
56
|
+
|
|
57
|
+
## Environment variables (contract)
|
|
58
|
+
|
|
59
|
+
Per-app prefix model (example uses `TELECLAUDE_`):
|
|
60
|
+
|
|
61
|
+
- `TELECLAUDE_LOG_LEVEL`
|
|
62
|
+
- `TELECLAUDE_THIRD_PARTY_LOG_LEVEL`
|
|
63
|
+
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
64
|
+
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
65
|
+
|
|
66
|
+
Global:
|
|
67
|
+
|
|
68
|
+
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
69
|
+
|
|
70
|
+
## Log location (contract)
|
|
71
|
+
|
|
72
|
+
Default target:
|
|
73
|
+
|
|
74
|
+
- `/var/log/instrukt-ai/{app}/{app}.log`
|
|
75
|
+
|
|
76
|
+
The installer for each service is expected to create the directory and set write permissions for the daemon user. If the default location is not writable, the implementation will fall back to a user-writable directory and/or require `INSTRUKT_AI_LOG_ROOT`.
|
|
77
|
+
|
|
78
|
+
## CLI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Last 10 minutes (default), follows
|
|
82
|
+
instrukt-ai-logs teleclaude -f
|
|
83
|
+
|
|
84
|
+
# Last 2 hours
|
|
85
|
+
instrukt-ai-logs teleclaude --since 2h
|
|
86
|
+
|
|
87
|
+
# Filter with regex
|
|
88
|
+
instrukt-ai-logs teleclaude --since 10m --grep 'logger=teleclaude'
|
|
89
|
+
|
|
90
|
+
# Follow (tail -f style) after printing the --since window
|
|
91
|
+
instrukt-ai-logs teleclaude --since 10m --follow
|
|
92
|
+
|
|
93
|
+
# Restrict to specific source streams (per-process files)
|
|
94
|
+
instrukt-ai-logs teleclaude --since 1h --logs=cron,docs-watch
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Without `--logs`, every `*.log*` file in the app directory is merged by
|
|
98
|
+
timestamp (rotation suffixes included). With `--logs=stem1,stem2`, only files
|
|
99
|
+
whose stem matches an entry are read — exact stem match, so `--logs=cron`
|
|
100
|
+
picks `cron.log`, `cron.log.0`, `cron.log.1` and never `cron-backup.log`.
|
|
101
|
+
Follow mode reads each selected file with one polling thread per file.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""InstruktAI logging standard library.
|
|
2
|
+
|
|
3
|
+
See `README.md` for usage and `docs/design.md` for design intent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"__version__",
|
|
8
|
+
"configure_logging",
|
|
9
|
+
"get_logger",
|
|
10
|
+
"InstruktAILogger",
|
|
11
|
+
"InstruktAILoggerProtocol",
|
|
12
|
+
"resolve_log_file",
|
|
13
|
+
"resolve_log_files",
|
|
14
|
+
"TRACE",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from importlib.metadata import version as _pkg_version
|
|
19
|
+
|
|
20
|
+
__version__ = _pkg_version("instrukt-ai-logger")
|
|
21
|
+
except Exception: # pragma: no cover
|
|
22
|
+
__version__ = "0.0.0"
|
|
23
|
+
|
|
24
|
+
from instrukt_ai_logging.logging import ( # noqa: E402 (intentional re-export)
|
|
25
|
+
configure_logging,
|
|
26
|
+
get_logger,
|
|
27
|
+
InstruktAILogger,
|
|
28
|
+
InstruktAILoggerProtocol,
|
|
29
|
+
resolve_log_file,
|
|
30
|
+
resolve_log_files,
|
|
31
|
+
TRACE,
|
|
32
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
from instrukt_ai_logging.logging import (
|
|
13
|
+
iter_recent_log_lines_merged,
|
|
14
|
+
parse_since,
|
|
15
|
+
resolve_log_files,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iter_follow_lines(
|
|
20
|
+
log_file: Path,
|
|
21
|
+
*,
|
|
22
|
+
poll_interval_s: float = 0.25,
|
|
23
|
+
start_at_end: bool = True,
|
|
24
|
+
max_lines: int | None = None,
|
|
25
|
+
max_seconds: float | None = None,
|
|
26
|
+
) -> Iterator[str]:
|
|
27
|
+
"""Yield new lines appended to `log_file`, similar to `tail -f`.
|
|
28
|
+
|
|
29
|
+
- Detects rotation (inode change) and truncation.
|
|
30
|
+
- Waits for file creation if it doesn't exist yet.
|
|
31
|
+
- `max_lines`/`max_seconds` are mainly for tests.
|
|
32
|
+
"""
|
|
33
|
+
deadline = None if max_seconds is None else (time.monotonic() + max_seconds)
|
|
34
|
+
emitted = 0
|
|
35
|
+
|
|
36
|
+
f = None
|
|
37
|
+
inode = None
|
|
38
|
+
start_at_end_for_next_open = start_at_end
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
while True:
|
|
42
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if f is None:
|
|
46
|
+
try:
|
|
47
|
+
f = log_file.open("r", encoding="utf-8", errors="replace")
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
time.sleep(poll_interval_s)
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
inode = os.fstat(f.fileno()).st_ino
|
|
54
|
+
except OSError:
|
|
55
|
+
inode = None
|
|
56
|
+
|
|
57
|
+
if start_at_end_for_next_open:
|
|
58
|
+
f.seek(0, os.SEEK_END)
|
|
59
|
+
else:
|
|
60
|
+
f.seek(0, os.SEEK_SET)
|
|
61
|
+
|
|
62
|
+
# Only the first open may start at end; after rotation we read from start.
|
|
63
|
+
start_at_end_for_next_open = False
|
|
64
|
+
|
|
65
|
+
line = f.readline()
|
|
66
|
+
if line:
|
|
67
|
+
yield line
|
|
68
|
+
emitted += 1
|
|
69
|
+
if max_lines is not None and emitted >= max_lines:
|
|
70
|
+
return
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# No new data: detect rotation/truncation and wait.
|
|
74
|
+
try:
|
|
75
|
+
st = log_file.stat()
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
f.close()
|
|
78
|
+
f = None
|
|
79
|
+
inode = None
|
|
80
|
+
time.sleep(poll_interval_s)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
if inode is not None and getattr(st, "st_ino", None) is not None and st.st_ino != inode:
|
|
84
|
+
f.close()
|
|
85
|
+
f = None
|
|
86
|
+
inode = None
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if f.tell() > st.st_size:
|
|
90
|
+
f.seek(0, os.SEEK_SET)
|
|
91
|
+
|
|
92
|
+
time.sleep(poll_interval_s)
|
|
93
|
+
finally:
|
|
94
|
+
if f is not None:
|
|
95
|
+
try:
|
|
96
|
+
f.close()
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_stems(value: str) -> list[str]:
|
|
102
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stem_of(path: Path) -> str:
|
|
106
|
+
"""Return the stem before the first `.log` suffix (e.g. `cron.log.1` → `cron`)."""
|
|
107
|
+
name = path.name
|
|
108
|
+
idx = name.find(".log")
|
|
109
|
+
return name[:idx] if idx > 0 else name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_rotation_suffix(path: Path) -> bool:
|
|
113
|
+
"""True for `<stem>.log.<n>` rotation files (and friends), false for `<stem>.log`."""
|
|
114
|
+
name = path.name
|
|
115
|
+
idx = name.find(".log")
|
|
116
|
+
return idx > 0 and name[idx + len(".log") :] != ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
parser = argparse.ArgumentParser(prog="instrukt-ai-logs", add_help=True)
|
|
121
|
+
parser.add_argument("app", help="App/service name (folder under /var/log/instrukt-ai/)")
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m"
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"-f",
|
|
127
|
+
"--follow",
|
|
128
|
+
action="store_true",
|
|
129
|
+
help="Follow the log (tail -f style) after printing the --since window",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument("--grep", default="", help="Regex to filter lines (optional)")
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--logs",
|
|
134
|
+
default="",
|
|
135
|
+
help=(
|
|
136
|
+
"Comma-separated list of stems to include (e.g. 'cron,docs-watch'). "
|
|
137
|
+
"Default: all log files in the app's directory."
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
args = parser.parse_args()
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
since = parse_since(args.since)
|
|
144
|
+
except ValueError as e:
|
|
145
|
+
raise SystemExit(str(e)) from e
|
|
146
|
+
|
|
147
|
+
pattern = re.compile(args.grep) if args.grep else None
|
|
148
|
+
stems = _parse_stems(args.logs) or None
|
|
149
|
+
|
|
150
|
+
files = resolve_log_files(args.app, stems=stems)
|
|
151
|
+
if not files:
|
|
152
|
+
if stems:
|
|
153
|
+
available = sorted({_stem_of(p) for p in resolve_log_files(args.app)})
|
|
154
|
+
available_str = ", ".join(available) if available else "(none)"
|
|
155
|
+
raise SystemExit(
|
|
156
|
+
f"No log files matched stems {stems} in app '{args.app}'. "
|
|
157
|
+
f"Available: {available_str}"
|
|
158
|
+
)
|
|
159
|
+
raise SystemExit(f"No log files found for app '{args.app}'")
|
|
160
|
+
|
|
161
|
+
for line in iter_recent_log_lines_merged(files, since):
|
|
162
|
+
if pattern and not pattern.search(line):
|
|
163
|
+
continue
|
|
164
|
+
sys.stdout.write(line)
|
|
165
|
+
|
|
166
|
+
if not args.follow:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
|
|
171
|
+
# Follow each selected file concurrently. New lines from any file are
|
|
172
|
+
# written to stdout as they arrive — no cross-file timestamp merge in
|
|
173
|
+
# follow mode, since real-time arrival ordering is its own merge.
|
|
174
|
+
# Filter to files that actually exist for the canonical (default) glob —
|
|
175
|
+
# for the explicit `--logs=` case, follow each named stem's canonical
|
|
176
|
+
# file (the unsuffixed `<stem>.log`) so we don't follow rotated history.
|
|
177
|
+
if stems is None:
|
|
178
|
+
follow_files = [p for p in files if not _is_rotation_suffix(p)]
|
|
179
|
+
else:
|
|
180
|
+
follow_files = []
|
|
181
|
+
for stem in stems:
|
|
182
|
+
for p in files:
|
|
183
|
+
if p.name == f"{stem}.log":
|
|
184
|
+
follow_files.append(p)
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
if not follow_files:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
stop_event = threading.Event()
|
|
191
|
+
|
|
192
|
+
def _follow_one(path: Path) -> None:
|
|
193
|
+
try:
|
|
194
|
+
for line in iter_follow_lines(path, start_at_end=True):
|
|
195
|
+
if stop_event.is_set():
|
|
196
|
+
return
|
|
197
|
+
if pattern and not pattern.search(line):
|
|
198
|
+
continue
|
|
199
|
+
sys.stdout.write(line)
|
|
200
|
+
sys.stdout.flush()
|
|
201
|
+
except OSError:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
threads = [
|
|
205
|
+
threading.Thread(target=_follow_one, args=(path,), daemon=True, name=f"follow-{path.name}")
|
|
206
|
+
for path in follow_files
|
|
207
|
+
]
|
|
208
|
+
for t in threads:
|
|
209
|
+
t.start()
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
while any(t.is_alive() for t in threads):
|
|
213
|
+
for t in threads:
|
|
214
|
+
t.join(timeout=0.25)
|
|
215
|
+
except KeyboardInterrupt:
|
|
216
|
+
stop_event.set()
|
|
217
|
+
return
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Install log rotation for InstruktAI logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _write_file(path: Path, content: str, *, force: bool) -> None:
|
|
12
|
+
if path.exists() and not force:
|
|
13
|
+
raise SystemExit(f"Refusing to overwrite existing file: {path}. Use --force.")
|
|
14
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
path.write_text(content, encoding="utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _install_newsyslog(log_root: Path, *, force: bool) -> Path:
|
|
19
|
+
conf_path = Path("/etc/newsyslog.d/instrukt-ai.conf")
|
|
20
|
+
line = f"{log_root}/" + "*/*.log 640 10 100000 * N\n"
|
|
21
|
+
_write_file(conf_path, line, force=force)
|
|
22
|
+
return conf_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _install_logrotate(log_root: Path, *, force: bool) -> Path:
|
|
26
|
+
conf_path = Path("/etc/logrotate.d/instrukt-ai")
|
|
27
|
+
content = f"""{log_root}/*/*.log {{
|
|
28
|
+
size 100M
|
|
29
|
+
rotate 10
|
|
30
|
+
missingok
|
|
31
|
+
notifempty
|
|
32
|
+
copytruncate
|
|
33
|
+
}}
|
|
34
|
+
"""
|
|
35
|
+
_write_file(conf_path, content, force=force)
|
|
36
|
+
return conf_path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> None:
|
|
40
|
+
parser = argparse.ArgumentParser(prog="instrukt-ai-log-setup", add_help=True)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--log-root",
|
|
43
|
+
default=os.environ.get("INSTRUKT_AI_LOG_ROOT", "/var/log/instrukt-ai"),
|
|
44
|
+
help="Base log root (default: /var/log/instrukt-ai or INSTRUKT_AI_LOG_ROOT)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--force", action="store_true", help="Overwrite existing config")
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
log_root = Path(args.log_root)
|
|
50
|
+
if sys.platform == "darwin":
|
|
51
|
+
conf = _install_newsyslog(log_root, force=args.force)
|
|
52
|
+
else:
|
|
53
|
+
conf = _install_logrotate(log_root, force=args.force)
|
|
54
|
+
|
|
55
|
+
sys.stdout.write(f"installed: {conf}\n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|