scheda 0.10.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.
scheda/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """scheda — a personal knowledge base that AI agents can query cheaply.
3
+
4
+ Design in one line
5
+ ------------------
6
+ The **single source of truth** is a directory of Markdown files on shared
7
+ storage (NAS / network share / any folder). Each machine keeps a **local
8
+ mirror** plus a **local full-text index**, so retrieval is millisecond-fast
9
+ and keeps working while the share is offline.
10
+
11
+ Nothing in this package assumes a particular vendor or path: everything
12
+ machine-specific lives in a config file outside the checkout.
13
+ """
14
+
15
+ __version__ = "0.10.0"
16
+
17
+ __all__ = ["__version__"]
scheda/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """``python -m scheda`` entry point."""
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
scheda/audit.py ADDED
@@ -0,0 +1,507 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """结构化事件日志 + 审计日志(JSON Lines)。
3
+
4
+ 为什么单独一个模块
5
+ ------------------
6
+ ``service.file_logger`` 原来的形态是「一行裸文本追加到 ``kb.log``」:没有级别、
7
+ 没有可过滤的字段、没有轮转,且 ``except OSError: pass`` 把「日志写不进去」这件
8
+ 事彻底淹没。对企业内网来说那三点都是硬伤 —— 出了事要能回答「谁、什么时候、
9
+ 查了什么」,排障时要能 ``jq 'select(.level=="error")'``,而磁盘满时运维必须
10
+ 收到提示而不是什么都没发生。
11
+
12
+ 文本日志因此**保留**(CLI 到处在用、格式已被依赖),结构化日志与它**并列**
13
+ 写一份:``<home>/logs/kb.log``(人读)+ ``<home>/logs/events.jsonl``(机读)+
14
+ ``<home>/logs/audit.jsonl``(审计)。三个文件各写各的,任何一个写失败都不
15
+ 影响另外两个。
16
+
17
+ 两个文件的分工是刻意的
18
+ ----------------------
19
+ * ``events.jsonl`` —— **运行**发生了什么(同步、建索引、搜索耗时)。
20
+ * ``audit.jsonl`` —— **谁看了什么**。只记元数据:动作、zone、截断后的查询串、
21
+ 目标路径、命中条数、传输通道。**绝不记页面正文、绝不记凭证**:审计日志是
22
+ 要长期保留、可能被第三方查看的东西,一旦开始记正文,知识库的隐私分区
23
+ (``z2-sensitive`` 之类)就在日志里被绕过了。
24
+
25
+ 轮转
26
+ ----
27
+ 单文件默认 5 MiB、保留 3 个历史文件(``.1`` ``.2`` ``.3``)。轮转用
28
+ ``os.replace``(同一文件系统上的原子改名)做 ``path → .1 → .2 …`` 的位移:
29
+ **不截断、不复制**,所以正在读旧文件的进程看到的还是完整内容。写入用
30
+ ``O_APPEND`` + 单次 ``os.write``,一行在 POSIX 上不会与另一个进程的行交错;
31
+ 轮转本身另加一把目录锁(见 :func:`_rotation_lock`)。并发下仍接受一个已知
32
+ 不完美:**可能有一行落在轮转前的旧文件里**(哪个文件是「新的」在改名的一瞬
33
+ 有歧义)—— 这是可以接受的,丢行不行,落错文件可以。
34
+
35
+ 过渡用的环境变量
36
+ ----------------
37
+ 轮转阈值与保留份数**本该**是 ``Config`` 的字段,但本版不动 ``config.py``
38
+ (配置面一改,既有 ``config.json`` 的语义就得重新论证,而这不是本次的目标)。
39
+ 所以本模块按「``Config`` 属性 → 环境变量 → 默认值」的顺序解析:
40
+
41
+ SCHEDA_LOG_MAX_BYTES 单文件阈值(字节),默认 5242880;0 = 不轮转
42
+ SCHEDA_LOG_BACKUPS 保留几个历史文件,默认 3;0 = 超阈值即丢弃旧文件
43
+
44
+ **这两个是过渡用的环境变量,下一版进 Config**(届时属性名 ``log_max_bytes`` /
45
+ ``log_backups``,解析顺序不变,环境变量继续作为覆盖层保留)。已经写好的调用
46
+ 方不必改。
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import errno
51
+ import json
52
+ import os
53
+ import sys
54
+ import threading
55
+ from contextlib import contextmanager
56
+ from datetime import datetime
57
+ from pathlib import Path
58
+
59
+ #: 允许的级别。固定四个 —— 再多就会出现「none / notice / critical」各自表述。
60
+ LEVELS: tuple[str, ...] = ("debug", "info", "warn", "error")
61
+
62
+ #: 审计动作的合法取值。这是「谁看了什么」的动词表,故意短。
63
+ ACTIONS: tuple[str, ...] = ("search", "read", "status", "skills", "push", "config-change")
64
+
65
+ #: 审计记录里的传输通道。
66
+ TRANSPORTS: tuple[str, ...] = ("cli", "mcp", "web")
67
+
68
+ #: 没传 ``actor`` 时的默认值:本机单一用户部署,机器身份就是 ``local``。
69
+ DEFAULT_ACTOR = "local"
70
+
71
+ #: 没传 ``transport`` 时的默认值(CLI 是最常见的入口)。
72
+ DEFAULT_TRANSPORT = "cli"
73
+
74
+ #: 审计里的查询串最长保留多少**字符**(不是字节)。截断而不是丢弃:前 200
75
+ #: 字符足够回答「他在找什么」,而整段查询可能是一整篇粘贴进来的文档。
76
+ MAX_QUERY_CHARS = 200
77
+
78
+ #: 单文件阈值:5 MiB。够放约 5 万条事件,翻一遍 ``jq`` 也在秒级。
79
+ DEFAULT_MAX_BYTES = 5 * 1024 * 1024
80
+
81
+ #: 历史文件份数。
82
+ DEFAULT_BACKUPS = 3
83
+
84
+ EVENTS_NAME = "events.jsonl"
85
+ AUDIT_NAME = "audit.jsonl"
86
+
87
+ #: ``extra`` 里允许的键。固定集合 = 下游的 ``jq`` 查询可以假定字段存在;
88
+ #: 不在表里的键被丢弃(并在 docstring 里说清楚,不静默改变语义)。
89
+ EXTRA_KEYS: tuple[str, ...] = (
90
+ "duration_ms", "pages", "chunks", "fetched", "removed", "error",
91
+ )
92
+
93
+ #: 进程内串行化。跨进程靠 ``O_APPEND`` 的单次写原子性,见模块 docstring。
94
+ _LOCK = threading.Lock()
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # 路径
99
+ # --------------------------------------------------------------------------- #
100
+ def log_dir(cfg) -> Path:
101
+ """日志目录 —— 与 ``cfg.log_path`` 同目录,不另立规则。"""
102
+ return Path(cfg.log_path).parent
103
+
104
+
105
+ def events_path(cfg) -> Path:
106
+ """结构化事件日志路径(``<home>/logs/events.jsonl``)。"""
107
+ return log_dir(cfg) / EVENTS_NAME
108
+
109
+
110
+ def audit_path(cfg) -> Path:
111
+ """审计日志路径(``<home>/logs/audit.jsonl``),与事件日志同目录、独立轮转。"""
112
+ return log_dir(cfg) / AUDIT_NAME
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # 轮转参数
117
+ # --------------------------------------------------------------------------- #
118
+ def _as_int(raw):
119
+ """把配置/环境里的值读成非负整数;读不出来返回 ``None``(让上层用默认值)。"""
120
+ if isinstance(raw, bool):
121
+ return int(raw)
122
+ try:
123
+ return int(str(raw).strip())
124
+ except (TypeError, ValueError):
125
+ return None
126
+
127
+
128
+ def _setting(cfg, names: tuple[str, ...], env: str, default: int) -> int:
129
+ for name in names:
130
+ value = _as_int(getattr(cfg, name, None))
131
+ if value is not None and value >= 0:
132
+ return value
133
+ value = _as_int(os.environ.get(env))
134
+ if value is not None and value >= 0:
135
+ return value
136
+ return default
137
+
138
+
139
+ def rotation_settings(cfg) -> tuple[int, int]:
140
+ """返回 ``(max_bytes, backups)``。
141
+
142
+ ``Config`` 上先找 ``log_max_bytes`` / ``log_backups``(下一版会有的字段,
143
+ 有就照它走);没有就退回 ``SCHEDA_LOG_MAX_BYTES`` / ``SCHEDA_LOG_BACKUPS``;
144
+ 再没有就用 5 MiB / 3 份。``max_bytes == 0`` 关闭轮转,``backups == 0``
145
+ 表示超阈值就地丢弃旧文件(不保留历史)。
146
+
147
+ 环境变量是**过渡手段**,见模块 docstring。
148
+ """
149
+ return (
150
+ _setting(cfg, ("log_max_bytes",), "SCHEDA_LOG_MAX_BYTES", DEFAULT_MAX_BYTES),
151
+ _setting(cfg, ("log_backups",), "SCHEDA_LOG_BACKUPS", DEFAULT_BACKUPS),
152
+ )
153
+
154
+
155
+ # --------------------------------------------------------------------------- #
156
+ # 底层写入
157
+ # --------------------------------------------------------------------------- #
158
+ def report_write_failure(exc: BaseException) -> None:
159
+ """把一次写失败报到 ``stderr`` —— 这是「不再静默吞掉」的全部实现。
160
+
161
+ 格式固定为 ``scheda: log write failed: <ERRNO>``(``errno`` 符号名,拿不到
162
+ 符号名时退化为异常类型名)。不做去重、不计数:日志写不进去通常意味着磁盘
163
+ 满或权限坏,重复恰恰是想要的信号。
164
+ """
165
+ code = getattr(exc, "errno", None)
166
+ label = errno.errorcode.get(code) if isinstance(code, int) else None
167
+ if label is None:
168
+ label = type(exc).__name__
169
+ try:
170
+ sys.stderr.write(f"scheda: log write failed: {label}\n")
171
+ sys.stderr.flush()
172
+ except Exception: # noqa: BLE001 - 连 stderr 都写不了,那就真的没有去处
173
+ pass
174
+
175
+
176
+ def _generation(path: Path, index: int) -> Path:
177
+ """``audit.jsonl`` 的第 ``index`` 代:``audit.jsonl.1`` …"""
178
+ return path.with_name(f"{path.name}.{index}")
179
+
180
+
181
+ @contextmanager
182
+ def _rotation_lock(folder: Path):
183
+ """轮转期间的跨进程互斥。
184
+
185
+ 锁**目录 fd** 而不是新建一个 ``.lock`` 文件:``flock`` 在 Linux 上对目录 fd
186
+ 同样有效,而多出来的锁文件会污染「日志目录里只有日志」这个可预期性(清理
187
+ 脚本按 ``*.jsonl*`` 扫的时候尤其明显)。拿不到 ``fcntl``(Windows)或锁不上
188
+ 时**照常轮转**:退化到模块 docstring 里说的「接受可能落错文件」,
189
+ 而不是因为锁不可用就干脆不轮转。
190
+ """
191
+ try:
192
+ import fcntl
193
+ except ImportError: # pragma: no cover - 只在 Windows 上走到
194
+ yield
195
+ return
196
+ handle = None
197
+ try:
198
+ handle = os.open(folder, os.O_RDONLY)
199
+ fcntl.flock(handle, fcntl.LOCK_EX)
200
+ except OSError: # pragma: no cover - 目录权限异常时才命中
201
+ if handle is not None:
202
+ os.close(handle)
203
+ handle = None
204
+ yield
205
+ return
206
+ try:
207
+ yield
208
+ finally:
209
+ try:
210
+ fcntl.flock(handle, fcntl.LOCK_UN)
211
+ finally:
212
+ os.close(handle)
213
+
214
+
215
+ def _rotate(path: Path, backups: int) -> None:
216
+ """``path → .1 → .2 …`` 位移,超过 ``backups`` 的最老那份被覆盖。
217
+
218
+ 全部用 ``os.replace``:同文件系统上是原子改名,任何时刻都**没有半个文件**,
219
+ 也不复制内容(大日志轮转 O(1))。缺某代(手删过、上次轮转被打断)时
220
+ ``FileNotFoundError`` 直接跳过 —— 不因为缺一代就不轮转。
221
+ """
222
+ if backups <= 0:
223
+ try:
224
+ os.unlink(path)
225
+ except FileNotFoundError:
226
+ pass
227
+ return
228
+ for index in range(backups - 1, 0, -1):
229
+ try:
230
+ os.replace(_generation(path, index), _generation(path, index + 1))
231
+ except FileNotFoundError:
232
+ continue
233
+ try:
234
+ os.replace(path, _generation(path, 1))
235
+ except FileNotFoundError:
236
+ pass
237
+
238
+
239
+ def _rotate_if_needed(path: Path, max_bytes: int, backups: int) -> None:
240
+ """写完一行后检查大小。**先写后转**:所以单文件最多超出最后一行。"""
241
+ if max_bytes <= 0:
242
+ return
243
+ try:
244
+ if path.stat().st_size < max_bytes:
245
+ return
246
+ except OSError: # pragma: no cover - stat 失败说明文件已经不在了
247
+ return
248
+ with _rotation_lock(path.parent):
249
+ try:
250
+ # 另一个进程可能刚刚转过:重查一次,避免一次写入触发两次位移。
251
+ if path.stat().st_size < max_bytes:
252
+ return
253
+ except OSError: # pragma: no cover
254
+ return
255
+ _rotate(path, backups)
256
+
257
+
258
+ def _write_all(fd: int, data: bytes) -> None:
259
+ view = memoryview(data)
260
+ while view:
261
+ written = os.write(fd, view)
262
+ view = view[written:]
263
+
264
+
265
+ def _write_jsonl(path, record: dict, *, max_bytes: int | None = None,
266
+ backups: int | None = None) -> None:
267
+ """追加一条记录(一行 JSON)。**任何异常都不上抛。**
268
+
269
+ 写失败只在 ``stderr`` 留一行 ``scheda: log write failed: <ERRNO>``:日志是
270
+ 观测手段,不能反过来成为新的失败源 —— 磁盘满时搜索仍然要能用。
271
+ """
272
+ threshold = DEFAULT_MAX_BYTES if max_bytes is None else int(max_bytes)
273
+ keep = DEFAULT_BACKUPS if backups is None else int(backups)
274
+ try:
275
+ payload = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
276
+ except (TypeError, ValueError) as exc: # 某个字段不可序列化(不该发生,兜住)
277
+ report_write_failure(exc)
278
+ return
279
+ try:
280
+ target = Path(path)
281
+ target.parent.mkdir(parents=True, exist_ok=True)
282
+ with _LOCK:
283
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
284
+ try:
285
+ _write_all(fd, payload)
286
+ finally:
287
+ os.close(fd)
288
+ _rotate_if_needed(target, threshold, keep)
289
+ except OSError as exc:
290
+ report_write_failure(exc)
291
+ except Exception as exc: # noqa: BLE001 - best effort,绝不把异常带给调用方
292
+ report_write_failure(exc)
293
+
294
+
295
+ # --------------------------------------------------------------------------- #
296
+ # 字段归一化
297
+ # --------------------------------------------------------------------------- #
298
+ def _one_line(text) -> str:
299
+ """把任意文本压成一行:JSON 里换行会被转义,但人读的 ``msg`` 不该带换行。"""
300
+ return str("" if text is None else text).replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
301
+
302
+
303
+ def _now() -> str:
304
+ """ISO8601、带本机时区、秒级 —— 与 ``service.file_logger`` 的文本前缀同源。"""
305
+ return datetime.now().astimezone().isoformat(timespec="seconds")
306
+
307
+
308
+ def normalise_level(level) -> str:
309
+ """归一化级别;不认识的值退到 ``info``(写日志这件事本身不能因为级别写错而丢)。"""
310
+ text = str(level if level is not None else "").strip().lower()
311
+ if text in ("warning", "warn"):
312
+ return "warn"
313
+ return text if text in LEVELS else "info"
314
+
315
+
316
+ def normalise_event(event) -> str:
317
+ """短横线命名:``Index Build!`` → ``index-build``;空值 → ``log``。"""
318
+ text = str(event if event is not None else "").strip().lower()
319
+ out = []
320
+ for char in text:
321
+ out.append(char if (char.isalnum() or char == "-") else "-")
322
+ slug = "".join(out).strip("-")
323
+ while "--" in slug:
324
+ slug = slug.replace("--", "-")
325
+ return slug or "log"
326
+
327
+
328
+ def _jsonable(value):
329
+ if isinstance(value, (str, int, float, bool)) or value is None:
330
+ return value
331
+ return str(value)
332
+
333
+
334
+ def _extra_fields(extra: dict) -> dict:
335
+ out = {}
336
+ for key, value in extra.items():
337
+ if key not in EXTRA_KEYS or value is None:
338
+ continue
339
+ out[key] = _jsonable(value)
340
+ return out
341
+
342
+
343
+ _ERROR_WORDS = ("failed", "failure", "error", "traceback", "refused", "cannot")
344
+ _WARN_WORDS = ("skipped", "skipping", "warn", "stale", "unavailable", "no usable",
345
+ "missing", "not found", "retry", "ignored")
346
+
347
+ #: 从自由文本猜事件名用的词表。刻意**粗**:``emit(message)`` 只有一句话,
348
+ #: 没有结构化信息可依;要精确就由调用方显式传 ``event=``。顺序无关,靠出现
349
+ #: 位置取最先命中的那个关键词。
350
+ _EVENT_HINTS: tuple[tuple[str, str], ...] = (
351
+ ("sync", "mirror-sync"),
352
+ ("mirror", "mirror-sync"),
353
+ ("pull", "mirror-sync"),
354
+ ("prune", "mirror-prune"),
355
+ ("index", "index-build"),
356
+ ("build", "index-build"),
357
+ ("chunk", "index-build"),
358
+ ("search", "search"),
359
+ ("read", "read"),
360
+ ("push", "push"),
361
+ ("config", "config-change"),
362
+ )
363
+
364
+
365
+ def guess_level(message) -> str:
366
+ """从一句人读消息猜级别(``error`` → ``warn`` → ``info`` 的优先级)。"""
367
+ text = _one_line(message).lower()
368
+ if any(word in text for word in _ERROR_WORDS):
369
+ return "error"
370
+ if any(word in text for word in _WARN_WORDS):
371
+ return "warn"
372
+ return "info"
373
+
374
+
375
+ def guess_event(message) -> str:
376
+ """从一句人读消息猜事件名;猜不出就是 ``log``(不编造语义)。"""
377
+ head = _one_line(message).split(":", 1)[0].lower()
378
+ best: tuple[int, str] | None = None
379
+ for keyword, event in _EVENT_HINTS:
380
+ position = head.find(keyword)
381
+ if position >= 0 and (best is None or position < best[0]):
382
+ best = (position, event)
383
+ return best[1] if best else "log"
384
+
385
+
386
+ # --------------------------------------------------------------------------- #
387
+ # 两个公开入口
388
+ # --------------------------------------------------------------------------- #
389
+ def log_event(cfg, level: str, event: str, msg: str, **extra) -> None:
390
+ """追加一条结构化事件到 ``events.jsonl``。**不抛异常。**
391
+
392
+ 记录形状固定:``ts`` / ``level`` / ``event`` / ``msg`` / 可选 ``extra``。
393
+ ``extra`` 只接受 :data:`EXTRA_KEYS` 里的键,其余丢弃 —— 下游的字段集合是
394
+ 契约,多一个键不会让 ``jq`` 更好用,只会让「这版有没有这个字段」变成猜谜。
395
+ """
396
+ record = {
397
+ "ts": _now(),
398
+ "level": normalise_level(level),
399
+ "event": normalise_event(event),
400
+ "msg": _one_line(msg),
401
+ }
402
+ fields = _extra_fields(extra)
403
+ if fields:
404
+ record["extra"] = fields
405
+ max_bytes, backups = rotation_settings(cfg)
406
+ _write_jsonl(events_path(cfg), record, max_bytes=max_bytes, backups=backups)
407
+
408
+
409
+ def _normalise_action(action) -> str:
410
+ text = str(action if action is not None else "").strip().lower()
411
+ return text or "unknown"
412
+
413
+
414
+ def _relative_path(cfg, value) -> str:
415
+ """审计里的 ``path``:尽量记**相对路径**(绝对路径会把 home 布局泄进日志)。
416
+
417
+ 绝对路径落在 ``mirror/`` 或 ``home`` 底下时就地相对化;落在外面时原样保留
418
+ —— 记错不如记全,但也不假装它是相对的。
419
+ """
420
+ text = _one_line(value).strip().replace("\\", "/")
421
+ if not text:
422
+ return ""
423
+ candidate = Path(text)
424
+ if candidate.is_absolute():
425
+ for base in (getattr(cfg, "mirror_dir", None), getattr(cfg, "home", None)):
426
+ if base is None:
427
+ continue
428
+ try:
429
+ return candidate.relative_to(Path(base)).as_posix()
430
+ except ValueError:
431
+ continue
432
+ return text
433
+ while text.startswith("./"):
434
+ text = text[2:]
435
+ return text
436
+
437
+
438
+ def _hits(value):
439
+ text = _as_int(value)
440
+ return text if text is not None and text >= 0 else None
441
+
442
+
443
+ def audit_event(cfg, action: str, **fields) -> None:
444
+ """追加一条审计记录到 ``audit.jsonl``。**不抛异常。**
445
+
446
+ 记录形状固定:``ts`` / ``actor`` / ``action`` / ``zone`` / ``query`` /
447
+ ``path`` / ``hits`` / ``lines`` / ``transport``。没提供的字段写空值(固定
448
+ schema 比「有的行有这个键、有的行没有」好用得多)。
449
+
450
+ ``lines`` 只有 ``read`` 会填:一条 read 记录若不知道读了多少行,事后无法
451
+ 区分「看了一眼标题」和「把整页拉走」。
452
+
453
+ 隐私红线(这段是承诺,不是注释):
454
+
455
+ * 只记**元数据**。正文、片段、frontmatter、凭证一律不进来 —— 调用方也不该
456
+ 把 ``snippet`` 塞进 ``fields``,那样会被忽略(不在固定字段里)。
457
+ * ``query`` 截断到 :data:`MAX_QUERY_CHARS` 字符。命中 ``z2`` 之类的字样
458
+ 不做任何特殊处理:那是用户自己敲进去的字符串,审计不该替它改写成
459
+ 「红acted」,否则「他查过什么」就答不出来了。
460
+ * 不认识的 ``action`` 照样落盘(审计完整性优先于 schema 洁癖),只是名字
461
+ 归一化成小写。
462
+ """
463
+ query = _one_line(fields.pop("query", "")).strip()
464
+ if len(query) > MAX_QUERY_CHARS:
465
+ query = query[:MAX_QUERY_CHARS]
466
+ transport = str(fields.pop("transport", "") or DEFAULT_TRANSPORT).strip().lower()
467
+ if transport not in TRANSPORTS:
468
+ transport = DEFAULT_TRANSPORT
469
+ record = {
470
+ "ts": _now(),
471
+ "actor": _one_line(fields.pop("actor", "") or DEFAULT_ACTOR).strip(),
472
+ "action": _normalise_action(action),
473
+ "zone": _one_line(fields.pop("zone", "")).strip(),
474
+ "query": query,
475
+ "path": _relative_path(cfg, fields.pop("path", "")),
476
+ "hits": _hits(fields.pop("hits", None)),
477
+ "lines": _hits(fields.pop("lines", None)),
478
+ "transport": transport,
479
+ }
480
+ max_bytes, backups = rotation_settings(cfg)
481
+ _write_jsonl(audit_path(cfg), record, max_bytes=max_bytes, backups=backups)
482
+
483
+
484
+ __all__ = [
485
+ "ACTIONS",
486
+ "AUDIT_NAME",
487
+ "DEFAULT_ACTOR",
488
+ "DEFAULT_BACKUPS",
489
+ "DEFAULT_MAX_BYTES",
490
+ "DEFAULT_TRANSPORT",
491
+ "EVENTS_NAME",
492
+ "EXTRA_KEYS",
493
+ "LEVELS",
494
+ "MAX_QUERY_CHARS",
495
+ "TRANSPORTS",
496
+ "audit_event",
497
+ "audit_path",
498
+ "events_path",
499
+ "guess_event",
500
+ "guess_level",
501
+ "log_dir",
502
+ "log_event",
503
+ "normalise_event",
504
+ "normalise_level",
505
+ "report_write_failure",
506
+ "rotation_settings",
507
+ ]