instruktai-python-logger 0.5.0__tar.gz → 0.6.0__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.5.0 → instruktai_python_logger-0.6.0}/PKG-INFO +24 -9
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/README.md +23 -8
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/cli.py +3 -1
- instruktai_python_logger-0.6.0/instrukt_ai_logging/install.py +232 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/logging.py +65 -13
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/PKG-INFO +24 -9
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/pyproject.toml +12 -3
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/tests/test_configure_logging.py +86 -6
- instruktai_python_logger-0.6.0/tests/test_install.py +255 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/tests/test_log_aggregation.py +59 -3
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/tests/test_trace_logging.py +2 -2
- instruktai_python_logger-0.5.0/instrukt_ai_logging/install.py +0 -96
- instruktai_python_logger-0.5.0/tests/test_install.py +0 -131
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/__init__.py +0 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/py.typed +0 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instruktai_python_logger.egg-info/top_level.txt +0 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/setup.cfg +0 -0
- {instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/tests/test_cli_follow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: instruktai-python-logger
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Centralized logging utilities for Python services.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -58,8 +58,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
61
|
-
under
|
|
62
|
-
(see below).
|
|
61
|
+
under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
|
|
62
|
+
merges them transparently (see below).
|
|
63
63
|
|
|
64
64
|
## Environment variables (contract)
|
|
65
65
|
|
|
@@ -70,17 +70,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
|
|
|
70
70
|
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
71
71
|
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
73
|
+
These four per-app variables are the entire contract. There is no log-root
|
|
74
|
+
override variable — the location is a fixed rule (see below).
|
|
76
75
|
|
|
77
76
|
## Log location (contract)
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
Every app's logs live at the same predictable, prose-citable location on both
|
|
79
|
+
macOS and Linux:
|
|
80
|
+
|
|
81
|
+
- `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
|
|
82
|
+
per the XDG Base Directory spec)
|
|
83
|
+
|
|
84
|
+
This path is always user-writable — no installer step, no sudo, no elevated
|
|
85
|
+
permissions. `configure_logging(...)` raises immediately if the log file
|
|
86
|
+
cannot be opened; logging is an essential subsystem and a process that cannot
|
|
87
|
+
write its logs must not start blind.
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
## Rotation
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
Rotation is transparent and runs entirely in user space:
|
|
92
|
+
`configure_logging(...)` idempotently ensures a per-user rotation conf plus a
|
|
93
|
+
per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
|
|
94
|
+
systemd user timer on Linux running `logrotate`) on every process start. No
|
|
95
|
+
`/etc` configuration, no sudo, and no system rotation daemon ever touches
|
|
96
|
+
these files, so rotated/recreated logs always stay owned by the producing
|
|
97
|
+
user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
|
|
98
|
+
normal operation never requires it.
|
|
84
99
|
|
|
85
100
|
## CLI
|
|
86
101
|
|
|
@@ -51,8 +51,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
54
|
-
under
|
|
55
|
-
(see below).
|
|
54
|
+
under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
|
|
55
|
+
merges them transparently (see below).
|
|
56
56
|
|
|
57
57
|
## Environment variables (contract)
|
|
58
58
|
|
|
@@ -63,17 +63,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
|
|
|
63
63
|
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
64
64
|
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
66
|
+
These four per-app variables are the entire contract. There is no log-root
|
|
67
|
+
override variable — the location is a fixed rule (see below).
|
|
69
68
|
|
|
70
69
|
## Log location (contract)
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
Every app's logs live at the same predictable, prose-citable location on both
|
|
72
|
+
macOS and Linux:
|
|
73
|
+
|
|
74
|
+
- `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
|
|
75
|
+
per the XDG Base Directory spec)
|
|
76
|
+
|
|
77
|
+
This path is always user-writable — no installer step, no sudo, no elevated
|
|
78
|
+
permissions. `configure_logging(...)` raises immediately if the log file
|
|
79
|
+
cannot be opened; logging is an essential subsystem and a process that cannot
|
|
80
|
+
write its logs must not start blind.
|
|
73
81
|
|
|
74
|
-
|
|
82
|
+
## Rotation
|
|
75
83
|
|
|
76
|
-
|
|
84
|
+
Rotation is transparent and runs entirely in user space:
|
|
85
|
+
`configure_logging(...)` idempotently ensures a per-user rotation conf plus a
|
|
86
|
+
per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
|
|
87
|
+
systemd user timer on Linux running `logrotate`) on every process start. No
|
|
88
|
+
`/etc` configuration, no sudo, and no system rotation daemon ever touches
|
|
89
|
+
these files, so rotated/recreated logs always stay owned by the producing
|
|
90
|
+
user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
|
|
91
|
+
normal operation never requires it.
|
|
77
92
|
|
|
78
93
|
## CLI
|
|
79
94
|
|
{instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/cli.py
RENAMED
|
@@ -192,7 +192,9 @@ def main() -> None:
|
|
|
192
192
|
action="version",
|
|
193
193
|
version=f"%(prog)s {__version__}",
|
|
194
194
|
)
|
|
195
|
-
parser.add_argument(
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"app", help="App/service name (folder under $XDG_STATE_HOME/instrukt-ai/, fallback ~/.local/state)"
|
|
197
|
+
)
|
|
196
198
|
parser.add_argument("--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m")
|
|
197
199
|
parser.add_argument(
|
|
198
200
|
"-f",
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Ensure user-space log rotation assets for InstruktAI logs.
|
|
2
|
+
|
|
3
|
+
Rotation runs entirely as the producing user: a per-user rotation conf plus a
|
|
4
|
+
per-user scheduler (launchd LaunchAgent on macOS, systemd user timer on
|
|
5
|
+
Linux). No `/etc` configuration, no sudo, no system rotation daemon ever
|
|
6
|
+
touches these files, so root-owned recreated logs are structurally
|
|
7
|
+
unexpressible.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_NEWSYSLOG_BIN = "/usr/sbin/newsyslog"
|
|
18
|
+
_LAUNCHD_LABEL = "ai.instrukt.log-rotate"
|
|
19
|
+
_SYSTEMD_UNIT_NAME = "instrukt-ai-logrotate"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _state_home_root() -> Path:
|
|
23
|
+
"""Resolve `$XDG_STATE_HOME/instrukt-ai` (fallback `~/.local/state`)."""
|
|
24
|
+
xdg_state_home = os.getenv("XDG_STATE_HOME")
|
|
25
|
+
state_home = Path(xdg_state_home).expanduser() if xdg_state_home else Path("~/.local/state").expanduser()
|
|
26
|
+
return state_home / "instrukt-ai"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _config_dir() -> Path:
|
|
30
|
+
return Path.home() / ".config" / "instrukt-ai"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _newsyslog_conf_path() -> Path:
|
|
34
|
+
return _config_dir() / "newsyslog.conf"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _launchd_plist_path() -> Path:
|
|
38
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{_LAUNCHD_LABEL}.plist"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _logrotate_conf_path() -> Path:
|
|
42
|
+
return _config_dir() / "logrotate.conf"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _systemd_user_dir() -> Path:
|
|
46
|
+
return Path.home() / ".config" / "systemd" / "user"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _systemd_service_path() -> Path:
|
|
50
|
+
return _systemd_user_dir() / f"{_SYSTEMD_UNIT_NAME}.service"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _systemd_timer_path() -> Path:
|
|
54
|
+
return _systemd_user_dir() / f"{_SYSTEMD_UNIT_NAME}.timer"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _newsyslog_conf(log_root: Path) -> str:
|
|
58
|
+
"""One glob line: 5 gzipped archives at 50 MB, evaluated at rotation time (G flag)."""
|
|
59
|
+
return f"{log_root}/*/*.log 640 5 50000 * ZG\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _launchd_plist(conf_path: Path) -> str:
|
|
63
|
+
# newsyslog refuses to run at all unless invoked as root or with -r; our
|
|
64
|
+
# conf lines carry no pid/signal field, so -r's "won't HUP syslogd" caveat
|
|
65
|
+
# doesn't apply here.
|
|
66
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
67
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
68
|
+
<plist version="1.0">
|
|
69
|
+
<dict>
|
|
70
|
+
<key>Label</key>
|
|
71
|
+
<string>{_LAUNCHD_LABEL}</string>
|
|
72
|
+
<key>ProgramArguments</key>
|
|
73
|
+
<array>
|
|
74
|
+
<string>{_NEWSYSLOG_BIN}</string>
|
|
75
|
+
<string>-r</string>
|
|
76
|
+
<string>-f</string>
|
|
77
|
+
<string>{conf_path}</string>
|
|
78
|
+
</array>
|
|
79
|
+
<key>StartInterval</key>
|
|
80
|
+
<integer>1800</integer>
|
|
81
|
+
<key>RunAtLoad</key>
|
|
82
|
+
<true/>
|
|
83
|
+
</dict>
|
|
84
|
+
</plist>
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _logrotate_conf(log_root: Path) -> str:
|
|
89
|
+
"""Default move+create rotation; WatchedFileHandler reopens on inode change."""
|
|
90
|
+
return f"""{log_root}/*/*.log {{
|
|
91
|
+
size 50M
|
|
92
|
+
rotate 5
|
|
93
|
+
compress
|
|
94
|
+
delaycompress
|
|
95
|
+
missingok
|
|
96
|
+
notifempty
|
|
97
|
+
}}
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _systemd_service(conf_path: Path, state_file: Path) -> str:
|
|
102
|
+
return f"""[Unit]
|
|
103
|
+
Description=InstruktAI log rotation
|
|
104
|
+
|
|
105
|
+
[Service]
|
|
106
|
+
Type=oneshot
|
|
107
|
+
ExecStart=logrotate --state {state_file} {conf_path}
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _systemd_timer() -> str:
|
|
112
|
+
return """[Unit]
|
|
113
|
+
Description=InstruktAI log rotation timer
|
|
114
|
+
|
|
115
|
+
[Timer]
|
|
116
|
+
OnCalendar=*:0/30
|
|
117
|
+
Persistent=true
|
|
118
|
+
|
|
119
|
+
[Install]
|
|
120
|
+
WantedBy=timers.target
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write_if_changed(path: Path, content: str) -> None:
|
|
125
|
+
if path.exists() and path.read_text(encoding="utf-8") == content:
|
|
126
|
+
return
|
|
127
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
path.write_text(content, encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_SCHEDULER_COMMAND_TIMEOUT_S = 10.0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_scheduler_command(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
135
|
+
# No Python API wraps launchd's per-user service bus or the systemd user
|
|
136
|
+
# D-Bus/session manager; shell out to launchctl/systemctl. Routed through
|
|
137
|
+
# this wrapper (rather than calling subprocess.run directly) so tests can
|
|
138
|
+
# fake just this boundary without patching the shared subprocess module.
|
|
139
|
+
# A bounded timeout and closed stdin keep an unreachable service bus (no
|
|
140
|
+
# GUI/user session, e.g. a headless CI runner) from hanging the caller
|
|
141
|
+
# forever instead of failing fast.
|
|
142
|
+
return subprocess.run(
|
|
143
|
+
args,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
stdin=subprocess.DEVNULL,
|
|
147
|
+
timeout=_SCHEDULER_COMMAND_TIMEOUT_S,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ensure_launchd_loaded(plist_path: Path) -> list[str]:
|
|
152
|
+
uid = os.getuid()
|
|
153
|
+
try:
|
|
154
|
+
result = _run_scheduler_command(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)])
|
|
155
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
156
|
+
return [f"launchctl bootstrap failed to run: {exc}"]
|
|
157
|
+
|
|
158
|
+
if result.returncode != 0 and "already bootstrapped" not in result.stderr.lower():
|
|
159
|
+
return [f"launchctl bootstrap failed: {result.stderr.strip()}"]
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _ensure_systemd_loaded() -> list[str]:
|
|
164
|
+
try:
|
|
165
|
+
reload_result = _run_scheduler_command(["systemctl", "--user", "daemon-reload"])
|
|
166
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
167
|
+
return [f"systemctl daemon-reload failed to run: {exc}"]
|
|
168
|
+
|
|
169
|
+
if reload_result.returncode != 0:
|
|
170
|
+
return [f"systemctl daemon-reload failed: {reload_result.stderr.strip()}"]
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
enable_result = _run_scheduler_command(
|
|
174
|
+
["systemctl", "--user", "enable", "--now", f"{_SYSTEMD_UNIT_NAME}.timer"]
|
|
175
|
+
)
|
|
176
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
177
|
+
return [f"systemctl enable failed to run: {exc}"]
|
|
178
|
+
|
|
179
|
+
if enable_result.returncode != 0:
|
|
180
|
+
return [f"systemctl enable failed: {enable_result.stderr.strip()}"]
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def ensure_rotation() -> list[str]:
|
|
185
|
+
"""Idempotently ensure user-space rotation assets exist and are scheduled.
|
|
186
|
+
|
|
187
|
+
Never raises. Returns human-readable problem strings for anything that
|
|
188
|
+
could not be resolved; an empty list means rotation is healthy.
|
|
189
|
+
"""
|
|
190
|
+
log_root = _state_home_root()
|
|
191
|
+
|
|
192
|
+
if sys.platform == "darwin":
|
|
193
|
+
conf_path = _newsyslog_conf_path()
|
|
194
|
+
plist_path = _launchd_plist_path()
|
|
195
|
+
try:
|
|
196
|
+
_write_if_changed(conf_path, _newsyslog_conf(log_root))
|
|
197
|
+
_write_if_changed(plist_path, _launchd_plist(conf_path))
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
return [f"could not write rotation assets: {exc}"]
|
|
200
|
+
return _ensure_launchd_loaded(plist_path)
|
|
201
|
+
|
|
202
|
+
conf_path = _logrotate_conf_path()
|
|
203
|
+
service_path = _systemd_service_path()
|
|
204
|
+
timer_path = _systemd_timer_path()
|
|
205
|
+
state_file = log_root / "logrotate.state"
|
|
206
|
+
try:
|
|
207
|
+
_write_if_changed(conf_path, _logrotate_conf(log_root))
|
|
208
|
+
_write_if_changed(service_path, _systemd_service(conf_path, state_file))
|
|
209
|
+
_write_if_changed(timer_path, _systemd_timer())
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
return [f"could not write rotation assets: {exc}"]
|
|
212
|
+
return _ensure_systemd_loaded()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main() -> None:
|
|
216
|
+
log_root = _state_home_root()
|
|
217
|
+
sys.stdout.write(f"log root: {log_root}\n")
|
|
218
|
+
sys.stdout.write(
|
|
219
|
+
f"rotation conf: {_newsyslog_conf_path() if sys.platform == 'darwin' else _logrotate_conf_path()}\n"
|
|
220
|
+
)
|
|
221
|
+
sys.stdout.write(f"scheduler: {_launchd_plist_path() if sys.platform == 'darwin' else _systemd_timer_path()}\n")
|
|
222
|
+
|
|
223
|
+
problems = ensure_rotation()
|
|
224
|
+
if problems:
|
|
225
|
+
for problem in problems:
|
|
226
|
+
sys.stdout.write(f"problem: {problem}\n")
|
|
227
|
+
raise SystemExit(1)
|
|
228
|
+
sys.stdout.write("status: healthy\n")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
main()
|
{instruktai_python_logger-0.5.0 → instruktai_python_logger-0.6.0}/instrukt_ai_logging/logging.py
RENAMED
|
@@ -11,6 +11,8 @@ from logging.handlers import WatchedFileHandler
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any, Protocol, cast, runtime_checkable
|
|
13
13
|
|
|
14
|
+
from instrukt_ai_logging.install import ensure_rotation
|
|
15
|
+
|
|
14
16
|
# Standard logging levels are: NOTSET=0, DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50
|
|
15
17
|
TRACE: int = 5
|
|
16
18
|
logging.addLevelName(TRACE, "TRACE")
|
|
@@ -345,10 +347,9 @@ class LoggingContract:
|
|
|
345
347
|
|
|
346
348
|
def _resolve_log_root(app_name: str) -> Path:
|
|
347
349
|
fs_app_name = _normalize_app_name(app_name)
|
|
348
|
-
|
|
349
|
-
if
|
|
350
|
-
|
|
351
|
-
return Path("/var/log/instrukt-ai") / fs_app_name
|
|
350
|
+
xdg_state_home = os.getenv("XDG_STATE_HOME")
|
|
351
|
+
state_home = Path(xdg_state_home).expanduser() if xdg_state_home else Path("~/.local/state").expanduser()
|
|
352
|
+
return state_home / "instrukt-ai" / fs_app_name
|
|
352
353
|
|
|
353
354
|
|
|
354
355
|
def _ensure_log_dir(log_dir: Path) -> None:
|
|
@@ -454,18 +455,19 @@ def configure_logging(
|
|
|
454
455
|
root_level = third_party_level if not spotlight else logging.WARNING
|
|
455
456
|
|
|
456
457
|
log_dir = _resolve_log_root(app_name)
|
|
457
|
-
_ensure_log_dir(log_dir)
|
|
458
|
-
|
|
459
458
|
log_file = log_dir / log_filename
|
|
460
459
|
|
|
461
460
|
formatter = LogfmtFormatter(max_message_chars=max_message_chars)
|
|
461
|
+
selector = _ThirdPartySelectorFilter(app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight))
|
|
462
|
+
|
|
463
|
+
# Logging is an essential subsystem: an unwritable log dir/file raises here
|
|
464
|
+
# and the host process must not start blind. No degraded/fallback mode.
|
|
465
|
+
_ensure_log_dir(log_dir)
|
|
466
|
+
handler: logging.Handler = WatchedFileHandler(log_file, encoding="utf-8")
|
|
462
467
|
|
|
463
|
-
handler = WatchedFileHandler(log_file, encoding="utf-8")
|
|
464
468
|
handler.setLevel(logging.NOTSET)
|
|
465
469
|
handler.setFormatter(formatter)
|
|
466
|
-
handler.addFilter(
|
|
467
|
-
_ThirdPartySelectorFilter(app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight))
|
|
468
|
-
)
|
|
470
|
+
handler.addFilter(selector)
|
|
469
471
|
|
|
470
472
|
# Configure root.
|
|
471
473
|
logging.root.handlers = [handler]
|
|
@@ -484,6 +486,11 @@ def configure_logging(
|
|
|
484
486
|
for prefix in muted:
|
|
485
487
|
logging.getLogger(prefix).setLevel(logging.WARNING)
|
|
486
488
|
|
|
489
|
+
# Rotation bootstrap is not logging: a failure here is surfaced as a
|
|
490
|
+
# warning, never fatal to an already-working log file.
|
|
491
|
+
for problem in ensure_rotation():
|
|
492
|
+
logging.getLogger(app_logger_prefix).warning("log rotation ensure problem: %s", problem)
|
|
493
|
+
|
|
487
494
|
return log_file
|
|
488
495
|
|
|
489
496
|
|
|
@@ -548,6 +555,46 @@ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
|
|
|
548
555
|
return lines
|
|
549
556
|
|
|
550
557
|
|
|
558
|
+
# Initial backward-probe size for locating the start of the --since window in a
|
|
559
|
+
# large file. A typical multi-minute window fits in one or two probes; the probe
|
|
560
|
+
# doubles when the window is larger, so worst case is still O(window), not O(file).
|
|
561
|
+
_WINDOW_PROBE_BYTES = 262144 # 256 KiB
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _window_start_offset(path: Path, cutoff: datetime) -> int:
|
|
565
|
+
"""Return a byte offset at or before the first line newer than `cutoff`.
|
|
566
|
+
|
|
567
|
+
A log file is appended in timestamp order, so the window's start can be found
|
|
568
|
+
by probing backward from EOF in exponentially growing chunks instead of
|
|
569
|
+
scanning from byte 0: once a probe's earliest timestamped line is already
|
|
570
|
+
older than the cutoff, the window begins within that probe and everything
|
|
571
|
+
before it is skippable. Returns 0 when the window spans the whole file (or the
|
|
572
|
+
file is smaller than a single probe), which reproduces a full forward scan.
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
size = path.stat().st_size
|
|
576
|
+
except OSError:
|
|
577
|
+
return 0
|
|
578
|
+
if size <= _WINDOW_PROBE_BYTES:
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
probe = _WINDOW_PROBE_BYTES
|
|
582
|
+
with path.open("rb") as fb:
|
|
583
|
+
while probe < size:
|
|
584
|
+
start = size - probe
|
|
585
|
+
fb.seek(start)
|
|
586
|
+
fb.readline() # discard the partial line spanning the probe boundary
|
|
587
|
+
first_ts: datetime | None = None
|
|
588
|
+
for raw in fb:
|
|
589
|
+
first_ts = parse_log_timestamp(raw.decode("utf-8", errors="replace"))
|
|
590
|
+
if first_ts is not None:
|
|
591
|
+
break
|
|
592
|
+
if first_ts is not None and first_ts < cutoff:
|
|
593
|
+
return start
|
|
594
|
+
probe *= 2
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
|
|
551
598
|
def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Iterator[str]:
|
|
552
599
|
"""Yield lines newer than now-`since` merged across multiple files by timestamp.
|
|
553
600
|
|
|
@@ -567,12 +614,17 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
|
|
|
567
614
|
|
|
568
615
|
def _file_stream(path: Path) -> Iterator[tuple[datetime, str]]:
|
|
569
616
|
try:
|
|
570
|
-
|
|
617
|
+
fb = path.open("rb")
|
|
571
618
|
except FileNotFoundError:
|
|
572
619
|
return
|
|
573
620
|
try:
|
|
621
|
+
offset = _window_start_offset(path, cutoff)
|
|
622
|
+
if offset:
|
|
623
|
+
fb.seek(offset)
|
|
624
|
+
fb.readline() # discard the partial line at the seek boundary
|
|
574
625
|
last_ts: datetime | None = None
|
|
575
|
-
for
|
|
626
|
+
for raw in fb:
|
|
627
|
+
line = raw.decode("utf-8", errors="replace")
|
|
576
628
|
ts = parse_log_timestamp(line)
|
|
577
629
|
if ts is None:
|
|
578
630
|
if last_ts is None or last_ts < cutoff:
|
|
@@ -584,7 +636,7 @@ def iter_recent_log_lines_merged(files: Iterable[Path], since: timedelta) -> Ite
|
|
|
584
636
|
continue
|
|
585
637
|
yield (ts, line)
|
|
586
638
|
finally:
|
|
587
|
-
|
|
639
|
+
fb.close()
|
|
588
640
|
|
|
589
641
|
eligible: list[Path] = []
|
|
590
642
|
for path in files:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: instruktai-python-logger
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Centralized logging utilities for Python services.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -58,8 +58,8 @@ configure_logging("teleclaude", source="cron") # → cron.log
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
The `.log` extension is always appended; pass the bare stem. Files all live
|
|
61
|
-
under
|
|
62
|
-
(see below).
|
|
61
|
+
under `$XDG_STATE_HOME/instrukt-ai/{app}/` (fallback `~/.local/state`). The CLI
|
|
62
|
+
merges them transparently (see below).
|
|
63
63
|
|
|
64
64
|
## Environment variables (contract)
|
|
65
65
|
|
|
@@ -70,17 +70,32 @@ Per-app prefix model (example uses `TELECLAUDE_`):
|
|
|
70
70
|
- `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes to spotlight, e.g. `httpcore,telegram`)
|
|
71
71
|
- `TELECLAUDE_MUTED_LOGGERS` (comma-separated logger prefixes to force to WARNING+, e.g. `teleclaude.cli.tui`)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- `INSTRUKT_AI_LOG_ROOT` (optional log root override)
|
|
73
|
+
These four per-app variables are the entire contract. There is no log-root
|
|
74
|
+
override variable — the location is a fixed rule (see below).
|
|
76
75
|
|
|
77
76
|
## Log location (contract)
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
Every app's logs live at the same predictable, prose-citable location on both
|
|
79
|
+
macOS and Linux:
|
|
80
|
+
|
|
81
|
+
- `$XDG_STATE_HOME/instrukt-ai/{app}/{app}.log` (fallback `~/.local/state`,
|
|
82
|
+
per the XDG Base Directory spec)
|
|
83
|
+
|
|
84
|
+
This path is always user-writable — no installer step, no sudo, no elevated
|
|
85
|
+
permissions. `configure_logging(...)` raises immediately if the log file
|
|
86
|
+
cannot be opened; logging is an essential subsystem and a process that cannot
|
|
87
|
+
write its logs must not start blind.
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
## Rotation
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
Rotation is transparent and runs entirely in user space:
|
|
92
|
+
`configure_logging(...)` idempotently ensures a per-user rotation conf plus a
|
|
93
|
+
per-user scheduler (a launchd LaunchAgent on macOS running `newsyslog`, a
|
|
94
|
+
systemd user timer on Linux running `logrotate`) on every process start. No
|
|
95
|
+
`/etc` configuration, no sudo, and no system rotation daemon ever touches
|
|
96
|
+
these files, so rotated/recreated logs always stay owned by the producing
|
|
97
|
+
user. Run `instrukt-ai-log-setup` to check rotation-asset status manually;
|
|
98
|
+
normal operation never requires it.
|
|
84
99
|
|
|
85
100
|
## CLI
|
|
86
101
|
|
|
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "instruktai-python-logger"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Centralized logging utilities for Python services."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
11
11
|
dependencies = []
|
|
12
12
|
|
|
13
13
|
[dependency-groups]
|
|
14
|
-
dev = ["pytest>=8", "ruff>=0.8", "pyright>=1.1", "mypy>=1.10"]
|
|
14
|
+
dev = ["pytest>=8", "pytest-timeout>=2.2.0", "ruff>=0.8", "pyright>=1.1", "mypy>=1.10"]
|
|
15
15
|
|
|
16
16
|
[project.scripts]
|
|
17
17
|
instrukt-ai-logs = "instrukt_ai_logging.cli:main"
|
|
@@ -25,8 +25,17 @@ include = ["instrukt_ai_logging*"]
|
|
|
25
25
|
instrukt_ai_logging = ["py.typed"]
|
|
26
26
|
|
|
27
27
|
[tool.pytest.ini_options]
|
|
28
|
-
addopts = "-q"
|
|
28
|
+
addopts = "--strict-config --strict-markers --tb=short -q"
|
|
29
29
|
testpaths = ["tests"]
|
|
30
|
+
timeout = 5
|
|
31
|
+
timeout_method = "signal"
|
|
32
|
+
session_timeout = 90
|
|
33
|
+
markers = [
|
|
34
|
+
"functional: fast, hermetic, no I/O beyond tmp_path",
|
|
35
|
+
"integration: exercises multiple in-process components together, still hermetic",
|
|
36
|
+
"process: drives an external process, bounded and hermetic (tmp_path/monkeypatch-isolated)",
|
|
37
|
+
"spec: binds a feature-spec scenario",
|
|
38
|
+
]
|
|
30
39
|
|
|
31
40
|
[tool.ruff]
|
|
32
41
|
line-length = 120
|