instruktai-python-logger 0.5.1__tar.gz → 0.6.1__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.
Files changed (21) hide show
  1. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/PKG-INFO +24 -9
  2. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/README.md +23 -8
  3. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/cli.py +3 -1
  4. instruktai_python_logger-0.6.1/instrukt_ai_logging/install.py +232 -0
  5. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/logging.py +17 -10
  6. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/PKG-INFO +24 -9
  7. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/pyproject.toml +12 -3
  8. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_configure_logging.py +86 -6
  9. instruktai_python_logger-0.6.1/tests/test_install.py +255 -0
  10. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_log_aggregation.py +3 -3
  11. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/tests/test_trace_logging.py +2 -2
  12. instruktai_python_logger-0.5.1/instrukt_ai_logging/install.py +0 -96
  13. instruktai_python_logger-0.5.1/tests/test_install.py +0 -131
  14. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/__init__.py +0 -0
  15. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instrukt_ai_logging/py.typed +0 -0
  16. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/SOURCES.txt +0 -0
  17. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/dependency_links.txt +0 -0
  18. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/entry_points.txt +0 -0
  19. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/instruktai_python_logger.egg-info/top_level.txt +0 -0
  20. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/setup.cfg +0 -0
  21. {instruktai_python_logger-0.5.1 → instruktai_python_logger-0.6.1}/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.5.1
3
+ Version: 0.6.1
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 `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
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
- Global:
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
- Default target:
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
- - `/var/log/instrukt-ai/{app}/{app}.log`
89
+ ## Rotation
82
90
 
83
- 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`.
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 `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
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
- Global:
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
- Default target:
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
- - `/var/log/instrukt-ai/{app}/{app}.log`
82
+ ## Rotation
75
83
 
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`.
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
 
@@ -192,7 +192,9 @@ def main() -> None:
192
192
  action="version",
193
193
  version=f"%(prog)s {__version__}",
194
194
  )
195
- parser.add_argument("app", help="App/service name (folder under /var/log/instrukt-ai/)")
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()
@@ -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
- override = os.getenv("INSTRUKT_AI_LOG_ROOT")
349
- if override:
350
- return Path(override).expanduser() / fs_app_name
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instruktai-python-logger
3
- Version: 0.5.1
3
+ Version: 0.6.1
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 `/var/log/instrukt-ai/{app}/`. The CLI merges them transparently
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
- Global:
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
- Default target:
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
- - `/var/log/instrukt-ai/{app}/{app}.log`
89
+ ## Rotation
82
90
 
83
- 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`.
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.5.1"
7
+ version = "0.6.1"
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
@@ -1,10 +1,11 @@
1
1
  import logging
2
+ import os
2
3
  from logging.handlers import WatchedFileHandler
3
4
  from pathlib import Path
4
5
  from tempfile import TemporaryDirectory
5
6
 
6
7
  import pytest
7
- from instrukt_ai_logging import InstruktAILogger, configure_logging, get_logger
8
+ from instrukt_ai_logging import InstruktAILogger, configure_logging, get_logger, install
8
9
 
9
10
  # Log markers shared by each test and its assertions — named constants per
10
11
  # software-development/procedure/snapshot-testing (no bare literals in content
@@ -39,6 +40,10 @@ _FIELD_MSG_TUI_WARNING = f'msg="{_MSG_TUI_WARNING}"'
39
40
  _FIELD_SESSION = f"session={_KV_SESSION}"
40
41
  _FIELD_N = f"n={_KV_N}"
41
42
 
43
+ _APP_NAME = "teleclaude"
44
+ _ROTATION_PROBLEM_MARKER = "log rotation ensure problem"
45
+ _ROTATION_FAILURE_REASON = "no session bus"
46
+
42
47
 
43
48
  @pytest.fixture()
44
49
  def isolated_logging():
@@ -63,7 +68,7 @@ def _read_text(path: Path) -> str:
63
68
 
64
69
  def test_our_logs_respect_app_level_and_third_party_baseline(isolated_logging, monkeypatch):
65
70
  with TemporaryDirectory() as tmp:
66
- monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
71
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
67
72
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "DEBUG")
68
73
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
69
74
  monkeypatch.delenv("TELECLAUDE_THIRD_PARTY_LOGGERS", raising=False)
@@ -81,7 +86,7 @@ def test_our_logs_respect_app_level_and_third_party_baseline(isolated_logging, m
81
86
 
82
87
  def test_configure_logging_uses_single_file_handler(isolated_logging, monkeypatch):
83
88
  with TemporaryDirectory() as tmp:
84
- monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
89
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
85
90
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
86
91
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
87
92
 
@@ -91,6 +96,81 @@ def test_configure_logging_uses_single_file_handler(isolated_logging, monkeypatc
91
96
  assert isinstance(logging.root.handlers[0], WatchedFileHandler)
92
97
 
93
98
 
99
+ def test_configure_logging_fails_fast_when_file_unwritable(isolated_logging, monkeypatch):
100
+ with TemporaryDirectory() as tmp:
101
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
102
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
103
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
104
+
105
+ # Pre-create the target log file at the path configure_logging resolves,
106
+ # then strip all permissions to simulate a root-owned/unwritable file
107
+ # left behind by a botched log rotation.
108
+ app_dir = Path(tmp) / "instrukt-ai" / _APP_NAME
109
+ app_dir.mkdir(parents=True, exist_ok=True)
110
+ log_path = app_dir / f"{_APP_NAME}.log"
111
+ log_path.write_text("", encoding="utf-8")
112
+ os.chmod(log_path, 0)
113
+
114
+ try:
115
+ # Logging is an essential subsystem: an unopenable log file must
116
+ # raise, never silently degrade.
117
+ with pytest.raises(PermissionError):
118
+ configure_logging(_APP_NAME)
119
+ finally:
120
+ # Restore permissions so the TemporaryDirectory cleanup can unlink it.
121
+ os.chmod(log_path, 0o644)
122
+
123
+
124
+ class _FakeFailingCompletedProcess:
125
+ def __init__(self) -> None:
126
+ self.returncode = 1
127
+ self.stderr = _ROTATION_FAILURE_REASON
128
+ self.stdout = ""
129
+
130
+
131
+ def test_configure_logging_warns_without_raising_when_rotation_ensure_fails(isolated_logging, monkeypatch):
132
+ with TemporaryDirectory() as tmp:
133
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
134
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
135
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
136
+
137
+ def _fake_run(args):
138
+ return _FakeFailingCompletedProcess()
139
+
140
+ monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
141
+
142
+ # A rotation-ensure failure must never crash logging — it's surfaced
143
+ # as a WARNING through the configured logger, not raised.
144
+ log_path = configure_logging(_APP_NAME)
145
+
146
+ content = _read_text(log_path)
147
+ assert _ROTATION_PROBLEM_MARKER in content
148
+ assert _ROTATION_FAILURE_REASON in content
149
+
150
+
151
+ def test_configure_logging_resolves_xdg_state_home_location(isolated_logging, monkeypatch):
152
+ with TemporaryDirectory() as tmp:
153
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
154
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
155
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
156
+
157
+ log_path = configure_logging(_APP_NAME)
158
+
159
+ assert log_path == Path(tmp) / "instrukt-ai" / _APP_NAME / f"{_APP_NAME}.log"
160
+
161
+
162
+ def test_configure_logging_falls_back_to_local_state_home_when_xdg_unset(isolated_logging, monkeypatch):
163
+ with TemporaryDirectory() as tmp:
164
+ monkeypatch.delenv("XDG_STATE_HOME", raising=False)
165
+ monkeypatch.setenv("HOME", tmp)
166
+ monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
167
+ monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
168
+
169
+ log_path = configure_logging(_APP_NAME)
170
+
171
+ assert log_path == Path(tmp) / ".local" / "state" / "instrukt-ai" / _APP_NAME / f"{_APP_NAME}.log"
172
+
173
+
94
174
  def test_get_logger_returns_instrukt_logger(isolated_logging):
95
175
  logger = get_logger(_LOGGER_OURS)
96
176
  assert isinstance(logger, InstruktAILogger)
@@ -99,7 +179,7 @@ def test_get_logger_returns_instrukt_logger(isolated_logging):
99
179
 
100
180
  def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatch):
101
181
  with TemporaryDirectory() as tmp:
102
- monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
182
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
103
183
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
104
184
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "INFO")
105
185
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOGGERS", "httpcore")
@@ -130,7 +210,7 @@ def test_spotlight_allows_selected_third_party_only(isolated_logging, monkeypatc
130
210
 
131
211
  def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
132
212
  with TemporaryDirectory() as tmp:
133
- monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
213
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
134
214
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "INFO")
135
215
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
136
216
 
@@ -146,7 +226,7 @@ def test_named_kv_logger_emits_pairs(isolated_logging, monkeypatch):
146
226
 
147
227
  def test_muted_loggers_forced_to_warning(isolated_logging, monkeypatch):
148
228
  with TemporaryDirectory() as tmp:
149
- monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
229
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
150
230
  monkeypatch.setenv("TELECLAUDE_LOG_LEVEL", "DEBUG")
151
231
  monkeypatch.setenv("TELECLAUDE_THIRD_PARTY_LOG_LEVEL", "WARNING")
152
232
  monkeypatch.setenv("TELECLAUDE_MUTED_LOGGERS", _LOGGER_MUTED)
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from instrukt_ai_logging import install
10
+
11
+ # Protocol-significant tokens for the rotation configs — named constants per
12
+ # software-development/procedure/snapshot-testing (no bare literals in content
13
+ # assertions). These are newsyslog/logrotate/launchd/systemd field identities,
14
+ # not prose.
15
+ _NEWSYSLOG_MODE = "640"
16
+ _NEWSYSLOG_COUNT = "5"
17
+ _NEWSYSLOG_SIZE_KB = "50000"
18
+ _NEWSYSLOG_WHEN = "*"
19
+ _NEWSYSLOG_COMPRESS_GLOB_FLAGS = "ZG" # gzip-compress archives; glob-expand at rotation time
20
+ _PLIST_LABEL = "ai.instrukt.log-rotate"
21
+ _PLIST_PROGRAM = "/usr/sbin/newsyslog"
22
+ _PLIST_NO_ROOT_FLAG = "<string>-r</string>" # newsyslog otherwise refuses to run as a non-root user
23
+ _PLIST_START_INTERVAL = "1800"
24
+ _LOGROTATE_SIZE = "size 50M"
25
+ _LOGROTATE_ROTATE = "rotate 5"
26
+ _LOGROTATE_COMPRESS = "compress"
27
+ _LOGROTATE_DELAYCOMPRESS = "delaycompress"
28
+ _LOGROTATE_MISSINGOK = "missingok"
29
+ _LOGROTATE_NOTIFEMPTY = "notifempty"
30
+ _SYSTEMD_EXEC_START_PREFIX = "ExecStart=logrotate"
31
+ _SYSTEMD_ONESHOT = "Type=oneshot"
32
+ _SYSTEMD_ON_CALENDAR = "OnCalendar=*:0/30"
33
+ _SYSTEMD_PERSISTENT = "Persistent=true"
34
+ _SYSTEMD_TIMER_UNIT = "instrukt-ai-logrotate.timer"
35
+ _PROBLEM_DAEMON_RELOAD = "daemon-reload"
36
+ _PROBLEM_WRITE_FAILURE = "could not write rotation assets"
37
+ _MAIN_LOG_ROOT_PREFIX = "log root:"
38
+ _MAIN_HEALTHY_STATUS = "status: healthy"
39
+ _MAIN_PROBLEM_PREFIX = "problem:"
40
+
41
+
42
+ class _FakeCompletedProcess:
43
+ def __init__(self, returncode: int, stderr: str = "") -> None:
44
+ self.returncode = returncode
45
+ self.stderr = stderr
46
+ self.stdout = ""
47
+
48
+
49
+ @pytest.fixture
50
+ def rotation_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
51
+ """Isolate the rotation-asset filesystem via XDG_STATE_HOME and HOME."""
52
+ state_home = tmp_path / "state"
53
+ home = tmp_path / "home"
54
+ state_home.mkdir(exist_ok=True)
55
+ home.mkdir(exist_ok=True)
56
+ monkeypatch.setenv("XDG_STATE_HOME", str(state_home))
57
+ monkeypatch.setenv("HOME", str(home))
58
+ return state_home
59
+
60
+
61
+ @pytest.fixture
62
+ def successful_subprocess(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
63
+ """Fake the scheduler subprocess boundary (launchctl/systemctl) as always succeeding."""
64
+ calls: list[list[str]] = []
65
+
66
+ def _fake_run(args: list[str]) -> _FakeCompletedProcess:
67
+ calls.append(args)
68
+ return _FakeCompletedProcess(returncode=0)
69
+
70
+ monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
71
+ return calls
72
+
73
+
74
+ def test_ensure_rotation_darwin_writes_newsyslog_conf_and_plist(
75
+ rotation_env: Path,
76
+ successful_subprocess: list[list[str]],
77
+ monkeypatch: pytest.MonkeyPatch,
78
+ ) -> None:
79
+ monkeypatch.setattr(sys, "platform", "darwin")
80
+
81
+ problems = install.ensure_rotation()
82
+
83
+ assert problems == []
84
+ conf_content = install._newsyslog_conf_path().read_text(encoding="utf-8")
85
+ path, mode, count, size_kb, when, flags = conf_content.split()
86
+ assert path == f"{rotation_env / 'instrukt-ai'}/*/*.log"
87
+ assert mode == _NEWSYSLOG_MODE
88
+ assert count == _NEWSYSLOG_COUNT
89
+ assert size_kb == _NEWSYSLOG_SIZE_KB
90
+ assert when == _NEWSYSLOG_WHEN
91
+ assert flags == _NEWSYSLOG_COMPRESS_GLOB_FLAGS
92
+
93
+ plist_content = install._launchd_plist_path().read_text(encoding="utf-8")
94
+ assert _PLIST_LABEL in plist_content
95
+ assert _PLIST_PROGRAM in plist_content
96
+ assert _PLIST_NO_ROOT_FLAG in plist_content
97
+ assert _PLIST_START_INTERVAL in plist_content
98
+
99
+ assert successful_subprocess == [
100
+ ["launchctl", "bootstrap", f"gui/{os.getuid()}", str(install._launchd_plist_path())]
101
+ ]
102
+
103
+
104
+ def test_ensure_rotation_linux_writes_logrotate_conf_and_systemd_units(
105
+ rotation_env: Path,
106
+ successful_subprocess: list[list[str]],
107
+ monkeypatch: pytest.MonkeyPatch,
108
+ ) -> None:
109
+ monkeypatch.setattr(sys, "platform", "linux")
110
+
111
+ problems = install.ensure_rotation()
112
+
113
+ assert problems == []
114
+ logrotate_content = install._logrotate_conf_path().read_text(encoding="utf-8")
115
+ assert f"{rotation_env / 'instrukt-ai'}/*/*.log" in logrotate_content
116
+ for directive in (
117
+ _LOGROTATE_SIZE,
118
+ _LOGROTATE_ROTATE,
119
+ _LOGROTATE_COMPRESS,
120
+ _LOGROTATE_DELAYCOMPRESS,
121
+ _LOGROTATE_MISSINGOK,
122
+ _LOGROTATE_NOTIFEMPTY,
123
+ ):
124
+ assert directive in logrotate_content
125
+ assert "su " not in logrotate_content # rotation already runs as the producing user
126
+
127
+ service_content = install._systemd_service_path().read_text(encoding="utf-8")
128
+ assert _SYSTEMD_EXEC_START_PREFIX in service_content
129
+ assert _SYSTEMD_ONESHOT in service_content
130
+
131
+ timer_content = install._systemd_timer_path().read_text(encoding="utf-8")
132
+ assert _SYSTEMD_ON_CALENDAR in timer_content
133
+ assert _SYSTEMD_PERSISTENT in timer_content
134
+
135
+ assert successful_subprocess == [
136
+ ["systemctl", "--user", "daemon-reload"],
137
+ ["systemctl", "--user", "enable", "--now", _SYSTEMD_TIMER_UNIT],
138
+ ]
139
+
140
+
141
+ def test_ensure_rotation_is_idempotent_across_runs(
142
+ rotation_env: Path,
143
+ successful_subprocess: list[list[str]],
144
+ monkeypatch: pytest.MonkeyPatch,
145
+ ) -> None:
146
+ monkeypatch.setattr(sys, "platform", "linux")
147
+
148
+ install.ensure_rotation()
149
+ conf_path = install._logrotate_conf_path()
150
+ first_mtime_ns = conf_path.stat().st_mtime_ns
151
+ first_content = conf_path.read_text(encoding="utf-8")
152
+
153
+ install.ensure_rotation()
154
+
155
+ assert conf_path.stat().st_mtime_ns == first_mtime_ns
156
+ assert conf_path.read_text(encoding="utf-8") == first_content
157
+
158
+
159
+ def test_ensure_rotation_reports_problem_when_scheduler_subprocess_fails(
160
+ rotation_env: Path, monkeypatch: pytest.MonkeyPatch
161
+ ) -> None:
162
+ monkeypatch.setattr(sys, "platform", "linux")
163
+
164
+ def _fake_run(args: list[str]) -> _FakeCompletedProcess:
165
+ return _FakeCompletedProcess(returncode=1, stderr="no session bus")
166
+
167
+ monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
168
+
169
+ problems = install.ensure_rotation()
170
+
171
+ assert problems
172
+ assert any(_PROBLEM_DAEMON_RELOAD in problem for problem in problems)
173
+
174
+
175
+ def test_ensure_rotation_reports_problem_when_scheduler_subprocess_times_out(
176
+ rotation_env: Path, monkeypatch: pytest.MonkeyPatch
177
+ ) -> None:
178
+ monkeypatch.setattr(sys, "platform", "linux")
179
+
180
+ def _fake_run(args: list[str]) -> _FakeCompletedProcess:
181
+ raise subprocess.TimeoutExpired(cmd=args, timeout=install._SCHEDULER_COMMAND_TIMEOUT_S)
182
+
183
+ monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
184
+
185
+ # An unreachable scheduler bus must be reported, never left to hang the
186
+ # caller indefinitely.
187
+ problems = install.ensure_rotation()
188
+
189
+ assert problems
190
+ assert any(_PROBLEM_DAEMON_RELOAD in problem for problem in problems)
191
+
192
+
193
+ def test_ensure_rotation_reports_problem_when_asset_write_fails(
194
+ rotation_env: Path, monkeypatch: pytest.MonkeyPatch
195
+ ) -> None:
196
+ monkeypatch.setattr(sys, "platform", "linux")
197
+
198
+ def _raise_write_error(path: Path, content: str) -> None:
199
+ raise OSError("read-only file system")
200
+
201
+ monkeypatch.setattr(install, "_write_if_changed", _raise_write_error)
202
+
203
+ # An unwritable rotation-asset directory must be reported, never left to
204
+ # propagate out of ensure_rotation()'s never-raises contract.
205
+ problems = install.ensure_rotation()
206
+
207
+ assert problems
208
+ assert any(_PROBLEM_WRITE_FAILURE in problem for problem in problems)
209
+
210
+
211
+ def test_ensure_rotation_tolerates_already_bootstrapped_launchd(
212
+ rotation_env: Path, monkeypatch: pytest.MonkeyPatch
213
+ ) -> None:
214
+ monkeypatch.setattr(sys, "platform", "darwin")
215
+
216
+ def _fake_run(args: list[str]) -> _FakeCompletedProcess:
217
+ return _FakeCompletedProcess(returncode=1, stderr="Service already bootstrapped")
218
+
219
+ monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
220
+
221
+ problems = install.ensure_rotation()
222
+
223
+ assert problems == []
224
+
225
+
226
+ def test_main_prints_status_and_exits_zero_when_healthy(
227
+ rotation_env: Path,
228
+ successful_subprocess: list[list[str]],
229
+ monkeypatch: pytest.MonkeyPatch,
230
+ capsys: pytest.CaptureFixture[str],
231
+ ) -> None:
232
+ monkeypatch.setattr(sys, "platform", "linux")
233
+
234
+ install.main()
235
+
236
+ out = capsys.readouterr().out
237
+ assert _MAIN_LOG_ROOT_PREFIX in out
238
+ assert _MAIN_HEALTHY_STATUS in out
239
+
240
+
241
+ def test_main_exits_non_zero_and_reports_problems(
242
+ rotation_env: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
243
+ ) -> None:
244
+ monkeypatch.setattr(sys, "platform", "linux")
245
+
246
+ def _fake_run(args: list[str]) -> _FakeCompletedProcess:
247
+ return _FakeCompletedProcess(returncode=1, stderr="boom")
248
+
249
+ monkeypatch.setattr(install, "_run_scheduler_command", _fake_run)
250
+
251
+ with pytest.raises(SystemExit):
252
+ install.main()
253
+
254
+ out = capsys.readouterr().out
255
+ assert _MAIN_PROBLEM_PREFIX in out
@@ -43,9 +43,9 @@ _LARGE_LINE_COUNT = 20000
43
43
  def app_log_dir(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
44
44
  tmp = TemporaryDirectory()
45
45
  root = Path(tmp.name)
46
- monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", str(root))
46
+ monkeypatch.setenv("XDG_STATE_HOME", str(root))
47
47
 
48
- app_dir = root / "demo-app"
48
+ app_dir = root / "instrukt-ai" / "demo-app"
49
49
  app_dir.mkdir(parents=True)
50
50
  yield app_dir
51
51
  tmp.cleanup()
@@ -83,7 +83,7 @@ def test_resolve_log_files_returns_empty_for_unknown_stem(app_log_dir: Path) ->
83
83
 
84
84
  def test_resolve_log_files_returns_empty_when_dir_missing(monkeypatch: pytest.MonkeyPatch) -> None:
85
85
  with TemporaryDirectory() as tmp:
86
- monkeypatch.setenv("INSTRUKT_AI_LOG_ROOT", tmp)
86
+ monkeypatch.setenv("XDG_STATE_HOME", tmp)
87
87
  # Directory for the app does not exist.
88
88
  assert resolve_log_files("nonexistent-app") == []
89
89
 
@@ -40,7 +40,7 @@ def test_trace_level_logging(clean_env):
40
40
 
41
41
  # Use a temporary directory for logs
42
42
  with tempfile.TemporaryDirectory() as temp_dir:
43
- os.environ["INSTRUKT_AI_LOG_ROOT"] = temp_dir
43
+ os.environ["XDG_STATE_HOME"] = temp_dir
44
44
 
45
45
  log_file = configure_logging(app_name)
46
46
  logger = get_logger("test_trace_app")
@@ -71,7 +71,7 @@ def test_trace_level_filtering(clean_env):
71
71
  os.environ["TEST_TRACE_FILTERING_LOG_LEVEL"] = "DEBUG"
72
72
 
73
73
  with tempfile.TemporaryDirectory() as temp_dir:
74
- os.environ["INSTRUKT_AI_LOG_ROOT"] = temp_dir
74
+ os.environ["XDG_STATE_HOME"] = temp_dir
75
75
 
76
76
  log_file = configure_logging(app_name)
77
77
  logger = get_logger("test_trace_filtering")
@@ -1,96 +0,0 @@
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
- DEFAULT_NEWSYSLOG_CONF = Path("/etc/newsyslog.d/instrukt-ai.conf")
11
- DEFAULT_LOGROTATE_CONF = Path("/etc/logrotate.d/instrukt-ai")
12
-
13
-
14
- def _write_file(path: Path, content: str, *, force: bool) -> None:
15
- if path.exists() and not force:
16
- raise SystemExit(f"Refusing to overwrite existing file: {path}. Use --force.")
17
- path.parent.mkdir(parents=True, exist_ok=True)
18
- path.write_text(content, encoding="utf-8")
19
-
20
-
21
- def _install_newsyslog(
22
- log_root: Path,
23
- *,
24
- force: bool,
25
- conf_path: Path = DEFAULT_NEWSYSLOG_CONF,
26
- ) -> Path:
27
- """Install newsyslog rules covering every app's *.log file.
28
-
29
- Format: path owner:group mode count size(KB) when flags
30
- Defaults: 50 MB rotation, 5 archives, gzip-compress archives (Z flag).
31
-
32
- macOS newsyslog requires literal filenames — its config does NOT
33
- expand globs at runtime. We therefore enumerate every existing
34
- *.log file under log_root and emit one explicit line per file.
35
- Re-run `instrukt-ai-log-setup --force` after a new producer creates
36
- its first log file (which can be wired into the app's installer or
37
- a daemon's periodic cleanup task).
38
- """
39
- if log_root.is_dir():
40
- log_files = sorted(p for p in log_root.glob("*/*.log") if p.is_file())
41
- else:
42
- log_files = []
43
- lines = "".join(f"{path} 640 5 50000 * Z\n" for path in log_files)
44
- if not lines:
45
- # No log files yet — write a placeholder comment so the conf is
46
- # discoverable on disk for future re-runs.
47
- lines = f"# instrukt-ai-log-setup: no *.log files found under {log_root}\n"
48
- _write_file(conf_path, lines, force=force)
49
- return conf_path
50
-
51
-
52
- def _install_logrotate(
53
- log_root: Path,
54
- *,
55
- force: bool,
56
- conf_path: Path = DEFAULT_LOGROTATE_CONF,
57
- ) -> Path:
58
- """Install a glob-based logrotate rule covering every app's *.log file.
59
-
60
- Defaults: 50 MB rotation, 5 archives, gzip-compress archives.
61
- """
62
- content = f"""{log_root}/*/*.log {{
63
- size 50M
64
- rotate 5
65
- compress
66
- delaycompress
67
- missingok
68
- notifempty
69
- copytruncate
70
- }}
71
- """
72
- _write_file(conf_path, content, force=force)
73
- return conf_path
74
-
75
-
76
- def main() -> None:
77
- parser = argparse.ArgumentParser(prog="instrukt-ai-log-setup", add_help=True)
78
- parser.add_argument(
79
- "--log-root",
80
- default=os.environ.get("INSTRUKT_AI_LOG_ROOT", "/var/log/instrukt-ai"),
81
- help="Base log root (default: /var/log/instrukt-ai or INSTRUKT_AI_LOG_ROOT)",
82
- )
83
- parser.add_argument("--force", action="store_true", help="Overwrite existing config")
84
- args = parser.parse_args()
85
-
86
- log_root = Path(args.log_root)
87
- if sys.platform == "darwin":
88
- conf = _install_newsyslog(log_root, force=args.force)
89
- else:
90
- conf = _install_logrotate(log_root, force=args.force)
91
-
92
- sys.stdout.write(f"installed: {conf}\n")
93
-
94
-
95
- if __name__ == "__main__":
96
- main()
@@ -1,131 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- import pytest
6
- from instrukt_ai_logging.install import _install_logrotate, _install_newsyslog
7
-
8
- # Protocol-significant tokens for the rotation configs — named constants per
9
- # software-development/procedure/snapshot-testing (no bare literals in content
10
- # assertions). These are newsyslog field identities, not prose.
11
- _NEWSYSLOG_MODE = "640"
12
- _NEWSYSLOG_COUNT = "5"
13
- _NEWSYSLOG_SIZE_KB = "50000"
14
- _NEWSYSLOG_WHEN = "*"
15
- _NEWSYSLOG_COMPRESS_FLAG = "Z" # gzip-compress archives
16
- _COMMENT_PREFIX = "#"
17
- _PREEXISTING_CONF = "preexisting"
18
-
19
-
20
- def test_install_newsyslog_emits_one_line_per_existing_log_file(tmp_path: Path) -> None:
21
- log_root = tmp_path / "logs"
22
- (log_root / "teleclaude").mkdir(parents=True)
23
- (log_root / "teleclaude" / "teleclaude.log").touch()
24
- (log_root / "teleclaude" / "cron.log").touch()
25
- (log_root / "teleclaude" / "docs-watch.log").touch()
26
- (log_root / "other-app").mkdir()
27
- (log_root / "other-app" / "other-app.log").touch()
28
- (log_root / "teleclaude" / "notes.txt").touch() # ignored: not *.log
29
- conf_path = tmp_path / "etc" / "newsyslog.d" / "instrukt-ai.conf"
30
-
31
- _install_newsyslog(log_root, force=False, conf_path=conf_path)
32
-
33
- rotated = [
34
- line.split()[0]
35
- for line in conf_path.read_text(encoding="utf-8").splitlines()
36
- if line and not line.startswith(_COMMENT_PREFIX)
37
- ]
38
- assert rotated == sorted(
39
- [
40
- str(log_root / "other-app" / "other-app.log"),
41
- str(log_root / "teleclaude" / "cron.log"),
42
- str(log_root / "teleclaude" / "docs-watch.log"),
43
- str(log_root / "teleclaude" / "teleclaude.log"),
44
- ]
45
- )
46
-
47
-
48
- def test_install_newsyslog_uses_compressing_rotation_defaults(tmp_path: Path) -> None:
49
- log_root = tmp_path / "logs"
50
- (log_root / "app").mkdir(parents=True)
51
- (log_root / "app" / "app.log").touch()
52
- conf_path = tmp_path / "newsyslog.conf"
53
-
54
- _install_newsyslog(log_root, force=False, conf_path=conf_path)
55
-
56
- fields = conf_path.read_text(encoding="utf-8").split()
57
- # path mode count size(KB) when flags
58
- _, mode, count, size_kb, when, flags = fields
59
- assert mode == _NEWSYSLOG_MODE
60
- assert count == _NEWSYSLOG_COUNT
61
- assert size_kb == _NEWSYSLOG_SIZE_KB
62
- assert when == _NEWSYSLOG_WHEN
63
- assert _NEWSYSLOG_COMPRESS_FLAG in flags # gzip-compress archives
64
-
65
-
66
- def test_install_newsyslog_writes_placeholder_when_no_log_files_exist(tmp_path: Path) -> None:
67
- log_root = tmp_path / "logs"
68
- log_root.mkdir() # exists but contains no *.log files
69
- conf_path = tmp_path / "newsyslog.conf"
70
-
71
- _install_newsyslog(log_root, force=False, conf_path=conf_path)
72
-
73
- content = conf_path.read_text(encoding="utf-8")
74
- assert content.startswith(_COMMENT_PREFIX)
75
- assert all(line.startswith(_COMMENT_PREFIX) or not line for line in content.splitlines())
76
-
77
-
78
- def test_install_newsyslog_refuses_overwrite_without_force(tmp_path: Path) -> None:
79
- log_root = tmp_path / "logs"
80
- (log_root / "app").mkdir(parents=True)
81
- (log_root / "app" / "app.log").touch()
82
- conf_path = tmp_path / "newsyslog.conf"
83
- conf_path.write_text(_PREEXISTING_CONF, encoding="utf-8")
84
-
85
- with pytest.raises(SystemExit):
86
- _install_newsyslog(log_root, force=False, conf_path=conf_path)
87
- assert conf_path.read_text(encoding="utf-8") == _PREEXISTING_CONF
88
-
89
-
90
- def test_install_newsyslog_overwrites_with_force(tmp_path: Path) -> None:
91
- log_root = tmp_path / "logs"
92
- (log_root / "app").mkdir(parents=True)
93
- (log_root / "app" / "app.log").touch()
94
- conf_path = tmp_path / "newsyslog.conf"
95
- conf_path.write_text(_PREEXISTING_CONF, encoding="utf-8")
96
-
97
- _install_newsyslog(log_root, force=True, conf_path=conf_path)
98
- assert conf_path.read_text(encoding="utf-8") != _PREEXISTING_CONF
99
-
100
-
101
- def test_install_logrotate_writes_glob_block_with_compressing_defaults(tmp_path: Path) -> None:
102
- log_root = tmp_path / "logs"
103
- log_root.mkdir()
104
- conf_path = tmp_path / "logrotate.conf"
105
-
106
- _install_logrotate(log_root, force=False, conf_path=conf_path)
107
-
108
- content = conf_path.read_text(encoding="utf-8")
109
- # logrotate handles multi-level globs natively, unlike macOS newsyslog.
110
- assert f"{log_root}/*/*.log" in content
111
- # Protocol-significant directives — these are logrotate keywords, not prose.
112
- for directive in (
113
- "size 50M",
114
- "rotate 5",
115
- "compress",
116
- "delaycompress",
117
- "missingok",
118
- "copytruncate",
119
- ):
120
- assert directive in content
121
-
122
-
123
- def test_install_logrotate_refuses_overwrite_without_force(tmp_path: Path) -> None:
124
- log_root = tmp_path / "logs"
125
- log_root.mkdir()
126
- conf_path = tmp_path / "logrotate.conf"
127
- conf_path.write_text(_PREEXISTING_CONF, encoding="utf-8")
128
-
129
- with pytest.raises(SystemExit):
130
- _install_logrotate(log_root, force=False, conf_path=conf_path)
131
- assert conf_path.read_text(encoding="utf-8") == _PREEXISTING_CONF