instruktai-python-logger 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,14 @@
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
+ "log_kv",
10
+ ]
11
+
12
+ __version__ = "0.0.0"
13
+
14
+ from instrukt_ai_logging.logging import configure_logging, log_kv # noqa: E402 (intentional re-export)
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from instrukt_ai_logging.logging import (
9
+ _fallback_log_root,
10
+ _resolve_log_root,
11
+ iter_recent_log_lines,
12
+ parse_since,
13
+ )
14
+
15
+
16
+ def _resolve_log_file(app: str) -> Path:
17
+ primary_dir = _resolve_log_root(app)
18
+ primary_file = primary_dir / f"{app}.log"
19
+ if primary_file.exists():
20
+ return primary_file
21
+
22
+ fallback_dir = _fallback_log_root(app)
23
+ fallback_file = fallback_dir / f"{app}.log"
24
+ if fallback_file.exists():
25
+ return fallback_file
26
+
27
+ return primary_file
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser(prog="instrukt-ai-logs", add_help=True)
32
+ parser.add_argument("app", help="App/service name (folder and filename stem)")
33
+ parser.add_argument(
34
+ "--since", default="10m", help="Time window (e.g. 10m, 2h, 1d). Default: 10m"
35
+ )
36
+ parser.add_argument("--grep", default="", help="Regex to filter lines (optional)")
37
+ args = parser.parse_args()
38
+
39
+ try:
40
+ since = parse_since(args.since)
41
+ except ValueError as e:
42
+ raise SystemExit(str(e)) from e
43
+
44
+ log_file = _resolve_log_file(args.app)
45
+ if not log_file.exists():
46
+ raise SystemExit(f"Log file not found: {log_file}")
47
+
48
+ pattern = re.compile(args.grep) if args.grep else None
49
+ for line in iter_recent_log_lines(log_file, since):
50
+ if pattern and not pattern.search(line):
51
+ continue
52
+ sys.stdout.write(line)
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timedelta, timezone
10
+ from logging.handlers import WatchedFileHandler
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ def _level_name_to_int(level_name: str, default: int) -> int:
16
+ name = level_name.strip().upper()
17
+ if not name:
18
+ return default
19
+ candidate: object = getattr(logging, name, None)
20
+ if isinstance(candidate, int):
21
+ return candidate
22
+ return default
23
+
24
+
25
+ def _parse_csv(value: str) -> list[str]:
26
+ return [item.strip() for item in value.split(",") if item.strip()]
27
+
28
+
29
+ def _now_utc() -> datetime:
30
+ return datetime.now(tz=timezone.utc)
31
+
32
+
33
+ class UtcMillisFormatter(logging.Formatter):
34
+ def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
35
+ dt = datetime.fromtimestamp(record.created, tz=timezone.utc)
36
+ if datefmt:
37
+ return dt.strftime(datefmt)
38
+ return dt.strftime("%Y-%m-%dT%H:%M:%S") + f".{int(record.msecs):03d}Z"
39
+
40
+
41
+ _SAFE_BARE_VALUE = re.compile(r"^[A-Za-z0-9._/:+-]+$")
42
+ _SAFE_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
43
+
44
+ # Keep this small and high-signal; expand only with strong justification.
45
+ _REDACTION_PATTERNS: list[tuple[re.Pattern[str], str]] = [
46
+ # Telegram bot token in API URLs: https://api.telegram.org/bot<token>/...
47
+ (re.compile(r"(api\\.telegram\\.org/bot)([^/\\s]+)"), r"\\1<REDACTED>"),
48
+ # Generic Bearer token
49
+ (re.compile(r"\\bBearer\\s+[^\\s]+"), "Bearer <REDACTED>"),
50
+ # Common OpenAI-style key prefix (avoid leaking)
51
+ (re.compile(r"\\bsk-[A-Za-z0-9]{10,}\\b"), "sk-<REDACTED>"),
52
+ ]
53
+
54
+
55
+ def _redact_text(text: str) -> str:
56
+ redacted = text
57
+ for pattern, replacement in _REDACTION_PATTERNS:
58
+ redacted = pattern.sub(replacement, redacted)
59
+ return redacted
60
+
61
+
62
+ def _truncate_text(text: str, max_chars: int) -> str:
63
+ if max_chars > 0 and len(text) > max_chars:
64
+ return text[:max_chars] + "…(truncated)"
65
+ return text
66
+
67
+
68
+ def _escape_quotes(value: str) -> str:
69
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")
70
+
71
+
72
+ def _format_logfmt_value(value: object, *, max_chars: int) -> str:
73
+ if value is None:
74
+ return "null"
75
+ if isinstance(value, bool):
76
+ return "true" if value else "false"
77
+ if isinstance(value, (int, float)):
78
+ return str(value)
79
+
80
+ text = _redact_text(str(value))
81
+ text = _truncate_text(text, max_chars=max_chars)
82
+
83
+ if _SAFE_BARE_VALUE.fullmatch(text):
84
+ return text
85
+ return f'"{_escape_quotes(text)}"'
86
+
87
+
88
+ def _format_logfmt_string(text: str, *, max_chars: int, force_quote: bool) -> str:
89
+ redacted = _redact_text(text)
90
+ redacted = _truncate_text(redacted, max_chars=max_chars)
91
+ if not force_quote and _SAFE_BARE_VALUE.fullmatch(redacted):
92
+ return redacted
93
+ return f'"{_escape_quotes(redacted)}"'
94
+
95
+
96
+ class LogfmtFormatter(UtcMillisFormatter):
97
+ """Single-line logfmt-ish formatter.
98
+
99
+ Always emits key/value pairs with at least:
100
+ level=..., logger=..., msg="..."
101
+
102
+ Optional additional pairs can be attached via `extra={"kv": {...}}`.
103
+ """
104
+
105
+ def __init__(self, max_message_chars: int) -> None:
106
+ super().__init__(datefmt=None)
107
+ self.max_message_chars = max_message_chars
108
+
109
+ def format(self, record: logging.LogRecord) -> str:
110
+ ts = self.formatTime(record)
111
+ try:
112
+ message = record.getMessage()
113
+ except Exception:
114
+ message = "<unprintable>"
115
+
116
+ parts = [
117
+ ts,
118
+ f"level={_format_logfmt_value(record.levelname, max_chars=self.max_message_chars)}",
119
+ f"logger={_format_logfmt_value(record.name, max_chars=self.max_message_chars)}",
120
+ f"msg={_format_logfmt_string(str(message), max_chars=self.max_message_chars, force_quote=True)}",
121
+ ]
122
+
123
+ kv: object = getattr(record, "kv", None)
124
+ if isinstance(kv, dict):
125
+ for key in sorted(kv.keys(), key=lambda x: str(x)):
126
+ if key == "msg":
127
+ continue
128
+ if not isinstance(key, str):
129
+ continue
130
+ if not _SAFE_KEY.fullmatch(key):
131
+ continue
132
+ parts.append(
133
+ f"{key}={_format_logfmt_value(kv[key], max_chars=self.max_message_chars)}"
134
+ )
135
+
136
+ if record.exc_info:
137
+ try:
138
+ exc_text = self.formatException(record.exc_info).replace("\n", "\\n")
139
+ except Exception:
140
+ exc_text = "<exception>"
141
+ parts.append(f"exc={_format_logfmt_value(exc_text, max_chars=self.max_message_chars)}")
142
+
143
+ return " ".join(parts)
144
+
145
+
146
+ def log_kv(logger: logging.Logger, level: int, data: Mapping[str, Any]) -> None:
147
+ """Log a key/value event.
148
+
149
+ `data` MUST include `msg` and may include any other serializable values.
150
+ """
151
+ if "msg" not in data:
152
+ raise ValueError('log_kv requires a "msg" key')
153
+
154
+ msg = str(data["msg"])
155
+ kv: dict[str, object] = {}
156
+ for key, value in data.items():
157
+ if key == "msg":
158
+ continue
159
+ if not _SAFE_KEY.fullmatch(key):
160
+ raise ValueError(f"Invalid key for log_kv: {key!r}")
161
+ kv[key] = value
162
+
163
+ logger.log(level, msg, extra={"kv": kv})
164
+
165
+
166
+ class _ThirdPartySelectorFilter(logging.Filter):
167
+ """Enforce third-party spotlight semantics on a handler.
168
+
169
+ - Always allow records from `app_logger_prefix`.
170
+ - For non-app loggers:
171
+ - If `spotlight_prefixes` is empty: allow (level gating happens via logger levels).
172
+ - If `spotlight_prefixes` is set: only allow records whose logger name starts with one of them.
173
+ """
174
+
175
+ def __init__(self, app_logger_prefix: str, spotlight_prefixes: tuple[str, ...]) -> None:
176
+ super().__init__()
177
+ self.app_logger_prefix = app_logger_prefix
178
+ self.spotlight_prefixes = spotlight_prefixes
179
+
180
+ def filter(self, record: logging.LogRecord) -> bool: # noqa: A003
181
+ name = record.name
182
+ if name == self.app_logger_prefix or name.startswith(self.app_logger_prefix + "."):
183
+ return True
184
+ if not self.spotlight_prefixes:
185
+ return True
186
+ return any(name == p or name.startswith(p + ".") for p in self.spotlight_prefixes)
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class LoggingContract:
191
+ env_prefix: str
192
+ app_logger_prefix: str
193
+ app_name: str
194
+
195
+ @property
196
+ def env_log_level(self) -> str:
197
+ return f"{self.env_prefix}_LOG_LEVEL"
198
+
199
+ @property
200
+ def env_third_party_level(self) -> str:
201
+ return f"{self.env_prefix}_THIRD_PARTY_LOG_LEVEL"
202
+
203
+ @property
204
+ def env_third_party_loggers(self) -> str:
205
+ return f"{self.env_prefix}_THIRD_PARTY_LOGGERS"
206
+
207
+
208
+ def _resolve_log_root(app_name: str) -> Path:
209
+ override = os.getenv("INSTRUKT_AI_LOG_ROOT")
210
+ if override:
211
+ return Path(override).expanduser()
212
+ return Path("/var/log/instrukt-ai") / app_name
213
+
214
+
215
+ def _fallback_log_root(app_name: str) -> Path:
216
+ # Deterministic fallback: repo-local ./logs if available, else /tmp.
217
+ candidate = Path.cwd() / "logs"
218
+ if candidate.exists() or candidate.parent.exists():
219
+ return candidate
220
+ return Path("/tmp") / "instrukt-ai" / app_name
221
+
222
+
223
+ def _ensure_log_dir(log_dir: Path) -> None:
224
+ log_dir.mkdir(parents=True, exist_ok=True)
225
+
226
+
227
+ def configure_logging(
228
+ *,
229
+ env_prefix: str,
230
+ app_logger_prefix: str,
231
+ app_name: str,
232
+ log_filename: str | None = None,
233
+ max_message_chars: int = 4000,
234
+ ) -> Path:
235
+ """Configure logging according to the InstruktAI contract.
236
+
237
+ Returns the resolved log file path in use.
238
+ """
239
+ contract = LoggingContract(
240
+ env_prefix=env_prefix,
241
+ app_logger_prefix=app_logger_prefix,
242
+ app_name=app_name,
243
+ )
244
+
245
+ our_level_name = (os.getenv(contract.env_log_level) or "INFO").upper()
246
+ third_party_level_name = (os.getenv(contract.env_third_party_level) or "WARNING").upper()
247
+ spotlight = _parse_csv(os.getenv(contract.env_third_party_loggers, ""))
248
+
249
+ our_level = _level_name_to_int(our_level_name, logging.INFO)
250
+ third_party_level = _level_name_to_int(third_party_level_name, logging.WARNING)
251
+
252
+ # Root logger governs all loggers that don't explicitly set a level.
253
+ root_level = third_party_level if not spotlight else logging.WARNING
254
+
255
+ log_dir = _resolve_log_root(app_name)
256
+ try:
257
+ _ensure_log_dir(log_dir)
258
+ except (PermissionError, OSError):
259
+ log_dir = _fallback_log_root(app_name)
260
+ _ensure_log_dir(log_dir)
261
+
262
+ log_file = log_dir / (log_filename or f"{app_name}.log")
263
+
264
+ formatter = LogfmtFormatter(max_message_chars=max_message_chars)
265
+
266
+ handler = WatchedFileHandler(log_file, encoding="utf-8")
267
+ handler.setLevel(logging.NOTSET)
268
+ handler.setFormatter(formatter)
269
+ handler.addFilter(
270
+ _ThirdPartySelectorFilter(
271
+ app_logger_prefix=app_logger_prefix, spotlight_prefixes=tuple(spotlight)
272
+ )
273
+ )
274
+
275
+ # Configure root.
276
+ logging.root.handlers = [handler]
277
+ logging.root.setLevel(root_level)
278
+
279
+ # Configure our logs.
280
+ logging.getLogger(app_logger_prefix).setLevel(our_level)
281
+
282
+ # Configure spotlight third-party prefixes (only affects non-app loggers).
283
+ for prefix in spotlight:
284
+ if prefix == app_logger_prefix or prefix.startswith(app_logger_prefix + "."):
285
+ continue
286
+ logging.getLogger(prefix).setLevel(third_party_level)
287
+
288
+ # Optional console output for interactive runs only.
289
+ if sys.stdout.isatty(): # type: ignore[misc]
290
+ console = logging.StreamHandler()
291
+ console.setLevel(logging.NOTSET)
292
+ console.setFormatter(formatter)
293
+ console.addFilter(
294
+ _ThirdPartySelectorFilter(
295
+ app_logger_prefix=app_logger_prefix,
296
+ spotlight_prefixes=tuple(spotlight),
297
+ )
298
+ )
299
+ logging.root.addHandler(console)
300
+
301
+ return log_file
302
+
303
+
304
+ def parse_since(value: str) -> timedelta:
305
+ """Parse durations like '10m', '2h', '1d', '30s'."""
306
+ raw = value.strip().lower()
307
+ if not raw:
308
+ raise ValueError("Empty duration")
309
+ unit = raw[-1]
310
+ number = raw[:-1]
311
+ if not number.isdigit():
312
+ raise ValueError(f"Invalid duration: {value}")
313
+ n = int(number)
314
+ if unit == "s":
315
+ return timedelta(seconds=n)
316
+ if unit == "m":
317
+ return timedelta(minutes=n)
318
+ if unit == "h":
319
+ return timedelta(hours=n)
320
+ if unit == "d":
321
+ return timedelta(days=n)
322
+ raise ValueError(f"Invalid duration unit: {unit}")
323
+
324
+
325
+ def parse_log_timestamp(line: str) -> datetime | None:
326
+ """Parse the timestamp at the start of a standard log line.
327
+
328
+ Expected: YYYY-MM-DDTHH:MM:SS.mmmZ ...
329
+ """
330
+ token = line.split(" ", 1)[0].strip()
331
+ if not token.endswith("Z"):
332
+ return None
333
+ try:
334
+ # Python doesn't accept 'Z' directly in fromisoformat.
335
+ return datetime.fromisoformat(token.replace("Z", "+00:00"))
336
+ except ValueError:
337
+ return None
338
+
339
+
340
+ def iter_recent_log_lines(log_file: Path, since: timedelta) -> list[str]:
341
+ """Return log lines newer than now-`since`, reading rotated siblings when present."""
342
+ cutoff = _now_utc() - since
343
+
344
+ candidates = sorted(
345
+ [
346
+ p
347
+ for p in log_file.parent.glob(log_file.name + "*")
348
+ if p.is_file() and not p.name.endswith(".gz")
349
+ ],
350
+ key=lambda p: p.stat().st_mtime,
351
+ )
352
+
353
+ lines: list[str] = []
354
+ for path in candidates:
355
+ try:
356
+ with path.open("r", encoding="utf-8", errors="replace") as f:
357
+ for line in f:
358
+ ts = parse_log_timestamp(line)
359
+ if ts is None:
360
+ continue
361
+ if ts >= cutoff:
362
+ lines.append(line)
363
+ except FileNotFoundError:
364
+ continue
365
+
366
+ return lines
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: instruktai-python-logger
3
+ Version: 0.1.0
4
+ Summary: Centralized logging utilities for InstruktAI 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 InstruktAI 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 instruktai-python-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
+ - Structured logging helper: `instrukt_ai_logging.log_kv(logger, level, {"msg": "...", ...})`
38
+ - CLI entrypoint: `instrukt-ai-logs` (reads recent log lines)
39
+
40
+ ## Environment variables (contract)
41
+
42
+ Per-app prefix model (example uses `TELECLAUDE_`):
43
+
44
+ - `TELECLAUDE_LOG_LEVEL`
45
+ - `TELECLAUDE_THIRD_PARTY_LOG_LEVEL`
46
+ - `TELECLAUDE_THIRD_PARTY_LOGGERS` (comma-separated logger prefixes, e.g. `httpcore,telegram`)
47
+
48
+ Global:
49
+
50
+ - `INSTRUKT_AI_LOG_ROOT` (optional log root override)
51
+
52
+ ## Log location (contract)
53
+
54
+ Default target:
55
+
56
+ - `/var/log/instrukt-ai/{app}/{app}.log`
57
+
58
+ 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`.
@@ -0,0 +1,8 @@
1
+ instrukt_ai_logging/__init__.py,sha256=WB5L0Qrl-NSDvVrrAtyrdZp2Ajm5aqAxWeZtS2f8KLk,313
2
+ instrukt_ai_logging/cli.py,sha256=XvcCqiyOlpugzbG0QfNqrQsT0eOemxJEfJRqUff9x4k,1485
3
+ instrukt_ai_logging/logging.py,sha256=bKlh9l92wYRY45w-ktliP8vAQqn4NrbUOXNKQkKXVa4,11893
4
+ instruktai_python_logger-0.1.0.dist-info/METADATA,sha256=yiyFgfIq0U7W4uU1P-7FNmMsV7nmCPNvEhwfiHldzls,1781
5
+ instruktai_python_logger-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ instruktai_python_logger-0.1.0.dist-info/entry_points.txt,sha256=Xq2g8G8Rqec3wUGKa7OOCedBTYMFvrddQh4g4PpS7cE,66
7
+ instruktai_python_logger-0.1.0.dist-info/top_level.txt,sha256=SkmwrXO5PioZPGXZeHwfjigT-3_RFqRiwemou6jPc34,20
8
+ instruktai_python_logger-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ instrukt-ai-logs = instrukt_ai_logging.cli:main
@@ -0,0 +1 @@
1
+ instrukt_ai_logging