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.
- instrukt_ai_logging/__init__.py +14 -0
- instrukt_ai_logging/cli.py +52 -0
- instrukt_ai_logging/logging.py +366 -0
- instruktai_python_logger-0.1.0.dist-info/METADATA +58 -0
- instruktai_python_logger-0.1.0.dist-info/RECORD +8 -0
- instruktai_python_logger-0.1.0.dist-info/WHEEL +5 -0
- instruktai_python_logger-0.1.0.dist-info/entry_points.txt +2 -0
- instruktai_python_logger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
instrukt_ai_logging
|