lorebase-platform 0.2.3__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.
- lorebase_platform-0.2.3/.gitignore +79 -0
- lorebase_platform-0.2.3/PKG-INFO +11 -0
- lorebase_platform-0.2.3/pyproject.toml +13 -0
- lorebase_platform-0.2.3/src/lorebase_platform/__init__.py +1 -0
- lorebase_platform-0.2.3/src/lorebase_platform/logging.py +160 -0
- lorebase_platform-0.2.3/src/lorebase_platform/secrets.py +103 -0
- lorebase_platform-0.2.3/src/lorebase_platform/settings.py +265 -0
- lorebase_platform-0.2.3/src/lorebase_platform/telemetry.py +92 -0
- lorebase_platform-0.2.3/tests/conftest.py +17 -0
- lorebase_platform-0.2.3/tests/test_logging.py +146 -0
- lorebase_platform-0.2.3/tests/test_secrets.py +103 -0
- lorebase_platform-0.2.3/tests/test_settings.py +168 -0
- lorebase_platform-0.2.3/tests/test_telemetry.py +80 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
.python-eggs/
|
|
14
|
+
pip-wheel-metadata/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.pyre/
|
|
19
|
+
.pytype/
|
|
20
|
+
.hypothesis/
|
|
21
|
+
.coverage
|
|
22
|
+
.coverage.*
|
|
23
|
+
coverage.xml
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
.nox/
|
|
27
|
+
*.prof
|
|
28
|
+
|
|
29
|
+
# Node
|
|
30
|
+
node_modules/
|
|
31
|
+
.pnpm-store/
|
|
32
|
+
.npm/
|
|
33
|
+
.yarn/
|
|
34
|
+
!.yarn/releases
|
|
35
|
+
.pnp.*
|
|
36
|
+
npm-debug.log*
|
|
37
|
+
yarn-debug.log*
|
|
38
|
+
yarn-error.log*
|
|
39
|
+
pnpm-debug.log*
|
|
40
|
+
.vite/
|
|
41
|
+
*.tsbuildinfo
|
|
42
|
+
apps/web/dist/
|
|
43
|
+
apps/web/.vitest/
|
|
44
|
+
|
|
45
|
+
# Environment and secrets
|
|
46
|
+
.env
|
|
47
|
+
.env.*
|
|
48
|
+
!.env.example
|
|
49
|
+
*.pem
|
|
50
|
+
*.key
|
|
51
|
+
secrets/
|
|
52
|
+
|
|
53
|
+
# Docker
|
|
54
|
+
docker-compose.override.yaml
|
|
55
|
+
docker-compose.override.yml
|
|
56
|
+
.docker/
|
|
57
|
+
|
|
58
|
+
# Data and local artifacts
|
|
59
|
+
*.sqlite
|
|
60
|
+
*.db
|
|
61
|
+
/tmp/
|
|
62
|
+
/data/
|
|
63
|
+
tests/perf/results/
|
|
64
|
+
|
|
65
|
+
# Editors and OS
|
|
66
|
+
.idea/
|
|
67
|
+
.vscode/*
|
|
68
|
+
!.vscode/extensions.json
|
|
69
|
+
!.vscode/settings.example.json
|
|
70
|
+
*.swp
|
|
71
|
+
*.swo
|
|
72
|
+
*~
|
|
73
|
+
.DS_Store
|
|
74
|
+
Thumbs.db
|
|
75
|
+
*.code-workspace
|
|
76
|
+
.history/
|
|
77
|
+
|
|
78
|
+
# Session worktrees
|
|
79
|
+
.claude/
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lorebase-platform
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Lorebase platform: settings, secrets, logging and telemetry
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: opentelemetry-api>=1.25
|
|
7
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.25
|
|
8
|
+
Requires-Dist: opentelemetry-sdk>=1.25
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.3
|
|
10
|
+
Requires-Dist: pydantic>=2.7
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lorebase-platform"
|
|
3
|
+
version = "0.2.3"
|
|
4
|
+
description = "Lorebase platform: settings, secrets, logging and telemetry"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = ["pydantic>=2.7", "pydantic-settings>=2.3", "pyyaml>=6.0", "opentelemetry-api>=1.25", "opentelemetry-sdk>=1.25", "opentelemetry-exporter-otlp-proto-grpc>=1.25"]
|
|
7
|
+
|
|
8
|
+
[build-system]
|
|
9
|
+
requires = ["hatchling"]
|
|
10
|
+
build-backend = "hatchling.build"
|
|
11
|
+
|
|
12
|
+
[tool.hatch.build.targets.wheel]
|
|
13
|
+
packages = ["src/lorebase_platform"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.3"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Structured logging: JSON records with trace correlation and secret redaction.
|
|
2
|
+
|
|
3
|
+
`configure_logging` installs one handler on the root logger. Every record passes through
|
|
4
|
+
`RedactionFilter`, which masks registered secret values found in messages, exception text and
|
|
5
|
+
extra fields, and masks the value of any field whose name looks sensitive.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Iterator, Mapping
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from typing import IO, Any
|
|
18
|
+
|
|
19
|
+
from opentelemetry import trace
|
|
20
|
+
from pydantic import SecretStr
|
|
21
|
+
|
|
22
|
+
from lorebase_platform.secrets import registered_secret_values
|
|
23
|
+
from lorebase_platform.settings import LoggingSettings
|
|
24
|
+
|
|
25
|
+
REDACTED = "***"
|
|
26
|
+
HANDLER_MARK = "_lorebase_handler"
|
|
27
|
+
|
|
28
|
+
request_id_var: ContextVar[str | None] = ContextVar("lorebase_request_id", default=None)
|
|
29
|
+
|
|
30
|
+
_STANDARD_ATTRIBUTES = frozenset(
|
|
31
|
+
logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys()
|
|
32
|
+
| {"message", "asctime", "taskName"}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextlib.contextmanager
|
|
37
|
+
def request_context(request_id: str) -> Iterator[None]:
|
|
38
|
+
"""Bind a request id to every log record emitted inside the block."""
|
|
39
|
+
token = request_id_var.set(request_id)
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
request_id_var.reset(token)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_sensitive_key(key: str, sensitive: tuple[str, ...]) -> bool:
|
|
47
|
+
"""True when `key` equals a sensitive name or contains it as underscore-separated parts."""
|
|
48
|
+
normalized = f"_{key.lower().replace('-', '_')}_"
|
|
49
|
+
return any(f"_{name.lower()}_" in normalized for name in sensitive)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RedactionFilter(logging.Filter):
|
|
53
|
+
"""Mask secrets in log records before any formatter sees them."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, sensitive_keys: tuple[str, ...], min_length: int) -> None:
|
|
56
|
+
super().__init__()
|
|
57
|
+
self._keys = sensitive_keys
|
|
58
|
+
self._min_length = min_length
|
|
59
|
+
|
|
60
|
+
def scrub_text(self, text: str) -> str:
|
|
61
|
+
"""Replace every registered secret value found in `text`."""
|
|
62
|
+
for secret in sorted(registered_secret_values(), key=len, reverse=True):
|
|
63
|
+
if len(secret) >= self._min_length and secret in text:
|
|
64
|
+
text = text.replace(secret, REDACTED)
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
def scrub(self, value: Any, key: str | None = None) -> Any:
|
|
68
|
+
"""Redact a value; a sensitive key masks the whole value."""
|
|
69
|
+
if key is not None and is_sensitive_key(key, self._keys):
|
|
70
|
+
return REDACTED
|
|
71
|
+
if isinstance(value, SecretStr):
|
|
72
|
+
return REDACTED
|
|
73
|
+
if isinstance(value, str):
|
|
74
|
+
return self.scrub_text(value)
|
|
75
|
+
if isinstance(value, Mapping):
|
|
76
|
+
return {str(k): self.scrub(v, str(k)) for k, v in value.items()}
|
|
77
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
78
|
+
return [self.scrub(item) for item in value]
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
82
|
+
record.msg = self.scrub_text(record.getMessage())
|
|
83
|
+
record.args = None
|
|
84
|
+
if record.exc_info and not record.exc_text:
|
|
85
|
+
record.exc_text = logging.Formatter().formatException(record.exc_info)
|
|
86
|
+
if record.exc_text:
|
|
87
|
+
record.exc_text = self.scrub_text(record.exc_text)
|
|
88
|
+
if record.stack_info:
|
|
89
|
+
record.stack_info = self.scrub_text(record.stack_info)
|
|
90
|
+
for name in set(record.__dict__) - _STANDARD_ATTRIBUTES:
|
|
91
|
+
record.__dict__[name] = self.scrub(record.__dict__[name], name)
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _trace_ids() -> dict[str, str]:
|
|
96
|
+
context = trace.get_current_span().get_span_context()
|
|
97
|
+
if not context.is_valid:
|
|
98
|
+
return {}
|
|
99
|
+
return {"trace_id": f"{context.trace_id:032x}", "span_id": f"{context.span_id:016x}"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class JsonFormatter(logging.Formatter):
|
|
103
|
+
"""One JSON object per line with trace and request correlation."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, service: str) -> None:
|
|
106
|
+
super().__init__()
|
|
107
|
+
self._service = service
|
|
108
|
+
|
|
109
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
110
|
+
payload: dict[str, Any] = {
|
|
111
|
+
"timestamp": datetime.fromtimestamp(record.created, UTC).isoformat(),
|
|
112
|
+
"level": record.levelname,
|
|
113
|
+
"logger": record.name,
|
|
114
|
+
"message": record.getMessage(),
|
|
115
|
+
"service": self._service,
|
|
116
|
+
**_trace_ids(),
|
|
117
|
+
}
|
|
118
|
+
request_id = request_id_var.get()
|
|
119
|
+
if request_id:
|
|
120
|
+
payload["request_id"] = request_id
|
|
121
|
+
for name, value in record.__dict__.items():
|
|
122
|
+
if name not in _STANDARD_ATTRIBUTES:
|
|
123
|
+
payload[name] = value
|
|
124
|
+
if record.exc_info and not record.exc_text:
|
|
125
|
+
record.exc_text = self.formatException(record.exc_info)
|
|
126
|
+
if record.exc_text:
|
|
127
|
+
payload["exception"] = record.exc_text
|
|
128
|
+
return json.dumps(payload, default=str, ensure_ascii=False)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TextFormatter(logging.Formatter):
|
|
132
|
+
"""Human-readable format that keeps the correlation identifiers."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, service: str) -> None:
|
|
135
|
+
super().__init__("%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
136
|
+
self._service = service
|
|
137
|
+
|
|
138
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
139
|
+
line = super().format(record)
|
|
140
|
+
ids = {**_trace_ids(), **({"request_id": r} if (r := request_id_var.get()) else {})}
|
|
141
|
+
suffix = " ".join(f"{k}={v}" for k, v in ids.items())
|
|
142
|
+
return f"{line} [{self._service}{' ' + suffix if suffix else ''}]"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def configure_logging(
|
|
146
|
+
settings: LoggingSettings, *, service: str, stream: IO[str] | None = None
|
|
147
|
+
) -> logging.Handler:
|
|
148
|
+
"""Install the structured handler on the root logger (idempotent) and return it."""
|
|
149
|
+
root = logging.getLogger()
|
|
150
|
+
for existing in [h for h in root.handlers if getattr(h, HANDLER_MARK, False)]:
|
|
151
|
+
root.removeHandler(existing)
|
|
152
|
+
handler = logging.StreamHandler(stream if stream is not None else sys.stderr)
|
|
153
|
+
setattr(handler, HANDLER_MARK, True)
|
|
154
|
+
handler.addFilter(RedactionFilter(settings.redact_keys, settings.redact_min_length))
|
|
155
|
+
handler.setFormatter(
|
|
156
|
+
JsonFormatter(service) if settings.format == "json" else TextFormatter(service)
|
|
157
|
+
)
|
|
158
|
+
root.addHandler(handler)
|
|
159
|
+
root.setLevel(settings.level)
|
|
160
|
+
return handler
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Secret references and the registry of secret values used for log redaction.
|
|
2
|
+
|
|
3
|
+
A secret setting holds either a literal value or a reference resolved at load time:
|
|
4
|
+
|
|
5
|
+
- `secret://env/NAME`: value of the environment variable `NAME`;
|
|
6
|
+
- `secret://file/PATH`: content of the file at `PATH` (trailing newline removed). Use a double
|
|
7
|
+
slash for an absolute path, for example `secret://file//run/secrets/db_password`.
|
|
8
|
+
|
|
9
|
+
Resolved values are typed `SecretStr` and are registered so that the logging redaction filter
|
|
10
|
+
can mask them wherever they appear.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Annotated, Any
|
|
21
|
+
|
|
22
|
+
from pydantic import BeforeValidator, SecretStr
|
|
23
|
+
|
|
24
|
+
REFERENCE_PREFIX = "secret://"
|
|
25
|
+
_REFERENCE = re.compile(r"^secret://(?P<provider>[a-z0-9_-]+)/(?P<target>.+)$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SecretResolutionError(ValueError):
|
|
29
|
+
"""A secret reference cannot be resolved. The message never contains a secret value."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _from_env(name: str) -> str:
|
|
33
|
+
value = os.environ.get(name)
|
|
34
|
+
if value is None:
|
|
35
|
+
raise SecretResolutionError(f"environment variable {name!r} is not set")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _from_file(path: str) -> str:
|
|
40
|
+
try:
|
|
41
|
+
content = Path(path).read_text(encoding="utf-8")
|
|
42
|
+
except OSError as exc:
|
|
43
|
+
raise SecretResolutionError(f"cannot read secret file {path!r}: {exc.strerror}") from None
|
|
44
|
+
return content.removesuffix("\n").removesuffix("\r")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_PROVIDERS: dict[str, Callable[[str], str]] = {"env": _from_env, "file": _from_file}
|
|
48
|
+
|
|
49
|
+
_known_values: set[str] = set()
|
|
50
|
+
_lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register_secret_value(value: str) -> None:
|
|
54
|
+
"""Remember a secret value so log redaction can mask it."""
|
|
55
|
+
if value:
|
|
56
|
+
with _lock:
|
|
57
|
+
_known_values.add(value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def registered_secret_values() -> frozenset[str]:
|
|
61
|
+
"""Snapshot of every secret value seen so far."""
|
|
62
|
+
with _lock:
|
|
63
|
+
return frozenset(_known_values)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_secret(value: str) -> str:
|
|
67
|
+
"""Return the secret behind a reference; a literal value is returned unchanged."""
|
|
68
|
+
if not value.startswith(REFERENCE_PREFIX):
|
|
69
|
+
register_secret_value(value)
|
|
70
|
+
return value
|
|
71
|
+
match = _REFERENCE.match(value)
|
|
72
|
+
if match is None:
|
|
73
|
+
raise SecretResolutionError(
|
|
74
|
+
f"malformed secret reference {value!r}; expected secret://env/NAME or "
|
|
75
|
+
"secret://file/PATH"
|
|
76
|
+
)
|
|
77
|
+
provider = _PROVIDERS.get(match["provider"])
|
|
78
|
+
if provider is None:
|
|
79
|
+
raise SecretResolutionError(
|
|
80
|
+
f"unsupported secret provider {match['provider']!r} in {value!r}; "
|
|
81
|
+
f"available: {', '.join(sorted(_PROVIDERS))}"
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
secret = provider(match["target"])
|
|
85
|
+
except SecretResolutionError as exc:
|
|
86
|
+
raise SecretResolutionError(f"{value}: {exc}") from None
|
|
87
|
+
if not secret:
|
|
88
|
+
raise SecretResolutionError(f"{value}: resolved to an empty value")
|
|
89
|
+
register_secret_value(secret)
|
|
90
|
+
return secret
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate(value: Any) -> Any:
|
|
94
|
+
if isinstance(value, SecretStr):
|
|
95
|
+
register_secret_value(value.get_secret_value())
|
|
96
|
+
return value
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
return resolve_secret(value)
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
SecretValue = Annotated[SecretStr, BeforeValidator(_validate)]
|
|
103
|
+
"""A `SecretStr` field that accepts a literal or a `secret://` reference."""
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Layered settings: code defaults, then a YAML file, then environment variables.
|
|
2
|
+
|
|
3
|
+
Sources, lowest to highest precedence:
|
|
4
|
+
|
|
5
|
+
1. defaults declared on the section models;
|
|
6
|
+
2. the YAML file named by `LOREBASE_CONFIG` (optional);
|
|
7
|
+
3. environment variables `LOREBASE__SECTION__KEY` (nested keys use further `__`), then the
|
|
8
|
+
short aliases a section registers (for example `LLM_BASE_URL`).
|
|
9
|
+
|
|
10
|
+
Sections owned by this package (`telemetry`, `logging`) are typed fields of `Settings`. Other
|
|
11
|
+
packages declare their section as a model and register it with `register_section`; they read it
|
|
12
|
+
with `Settings.section`. `load_settings` validates every registered section, so invalid
|
|
13
|
+
configuration stops the process at startup with a message naming each key and the reason.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, TypeVar
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
27
|
+
from pydantic.fields import FieldInfo
|
|
28
|
+
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
|
|
29
|
+
|
|
30
|
+
CONFIG_ENV_VAR = "LOREBASE_CONFIG"
|
|
31
|
+
ENV_PREFIX = "LOREBASE__"
|
|
32
|
+
ENV_DELIMITER = "__"
|
|
33
|
+
|
|
34
|
+
SectionT = TypeVar("SectionT", bound=BaseModel)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConfigError(Exception):
|
|
38
|
+
"""Invalid configuration. The message names every offending key and the reason."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, problems: list[str]) -> None:
|
|
41
|
+
self.problems = problems
|
|
42
|
+
super().__init__("invalid configuration:\n" + "\n".join(f" - {p}" for p in problems))
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_validation(cls, exc: ValidationError, prefix: str = "") -> ConfigError:
|
|
46
|
+
"""Build the error from pydantic details, never echoing input values."""
|
|
47
|
+
return cls(_problems(exc, prefix))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _problems(exc: ValidationError, prefix: str = "") -> list[str]:
|
|
51
|
+
problems = []
|
|
52
|
+
for error in exc.errors(include_input=False, include_url=False, include_context=False):
|
|
53
|
+
key = ".".join(str(part) for part in (*([prefix] if prefix else []), *error["loc"]))
|
|
54
|
+
problems.append(f"{key or '<root>'}: {error['msg']}")
|
|
55
|
+
return problems
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SectionModel(BaseModel):
|
|
59
|
+
"""Base class for settings sections: immutable, unknown keys rejected."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TelemetrySettings(SectionModel):
|
|
65
|
+
"""OpenTelemetry export. Disabled unless `otlp_endpoint` is set."""
|
|
66
|
+
|
|
67
|
+
otlp_endpoint: str | None = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="OTLP gRPC collector endpoint, e.g. http://otel-collector:4317. "
|
|
70
|
+
"Unset or empty disables traces and metrics.",
|
|
71
|
+
)
|
|
72
|
+
service_name: str = Field(default="lorebase", description="Value of `service.name`.")
|
|
73
|
+
service_namespace: str = Field(default="lorebase", description="Value of `service.namespace`.")
|
|
74
|
+
sample_ratio: float = Field(
|
|
75
|
+
default=0.1, ge=0, le=1, description="Fraction of root traces that are sampled."
|
|
76
|
+
)
|
|
77
|
+
metric_export_interval_s: float = Field(
|
|
78
|
+
default=60, gt=0, description="Seconds between metric exports."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@field_validator("otlp_endpoint")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _empty_means_unset(cls, value: str | None) -> str | None:
|
|
84
|
+
return value.strip() or None if value else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class LoggingSettings(SectionModel):
|
|
88
|
+
"""Structured logging and redaction."""
|
|
89
|
+
|
|
90
|
+
level: str = Field(default="INFO", description="Root log level name.")
|
|
91
|
+
format: str = Field(default="json", pattern="^(json|text)$", description="json or text.")
|
|
92
|
+
redact_keys: tuple[str, ...] = Field(
|
|
93
|
+
default=("password", "secret", "token", "api_key", "authorization", "credential"),
|
|
94
|
+
description="Field names whose values are masked. A name matches a whole "
|
|
95
|
+
"underscore-separated key or a part of it (access_token matches token).",
|
|
96
|
+
)
|
|
97
|
+
redact_min_length: int = Field(
|
|
98
|
+
default=4,
|
|
99
|
+
ge=1,
|
|
100
|
+
description="Secret values shorter than this are not masked inside free text.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@field_validator("level")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _known_level(cls, value: str) -> str:
|
|
106
|
+
name = value.upper()
|
|
107
|
+
if name not in {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"}:
|
|
108
|
+
raise ValueError("must be one of CRITICAL, ERROR, WARNING, INFO, DEBUG")
|
|
109
|
+
return name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class SectionSpec:
|
|
114
|
+
"""A settings section contributed by a package."""
|
|
115
|
+
|
|
116
|
+
name: str
|
|
117
|
+
model: type[BaseModel]
|
|
118
|
+
env_aliases: Mapping[str, str] = field(default_factory=dict)
|
|
119
|
+
"""Environment variable name -> dotted key inside the section, e.g. LLM_MODEL -> llm.model."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_SECTIONS: dict[str, SectionSpec] = {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def register_section(
|
|
126
|
+
name: str, model: type[BaseModel], env_aliases: Mapping[str, str] | None = None
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Declare a settings section; repeating the same declaration is harmless."""
|
|
129
|
+
if name in Settings.model_fields:
|
|
130
|
+
raise ValueError(f"section {name!r} is owned by lorebase_platform")
|
|
131
|
+
spec = SectionSpec(name, model, dict(env_aliases or {}))
|
|
132
|
+
existing = _SECTIONS.get(name)
|
|
133
|
+
if existing is not None and existing != spec:
|
|
134
|
+
raise ValueError(f"section {name!r} is already registered with a different model")
|
|
135
|
+
_SECTIONS[name] = spec
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def registered_sections() -> Mapping[str, SectionSpec]:
|
|
139
|
+
"""Sections registered so far."""
|
|
140
|
+
return dict(_SECTIONS)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_value(raw: str) -> Any:
|
|
144
|
+
stripped = raw.strip()
|
|
145
|
+
if stripped[:1] in ("[", "{"):
|
|
146
|
+
try:
|
|
147
|
+
return json.loads(stripped)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
return raw
|
|
150
|
+
return raw
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _assign(tree: dict[str, Any], path: list[str], value: Any, origin: str) -> None:
|
|
154
|
+
node = tree
|
|
155
|
+
for part in path[:-1]:
|
|
156
|
+
child = node.setdefault(part, {})
|
|
157
|
+
if not isinstance(child, dict):
|
|
158
|
+
raise ConfigError([f"{origin}: conflicts with a value set for {part!r}"])
|
|
159
|
+
node = child
|
|
160
|
+
if isinstance(node.get(path[-1]), dict):
|
|
161
|
+
raise ConfigError([f"{origin}: conflicts with nested keys under {path[-1]!r}"])
|
|
162
|
+
node[path[-1]] = value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def environment_overrides(environ: Mapping[str, str]) -> dict[str, Any]:
|
|
166
|
+
"""Nested mapping from `LOREBASE__A__B` variables and registered section aliases."""
|
|
167
|
+
tree: dict[str, Any] = {}
|
|
168
|
+
for name in sorted(environ):
|
|
169
|
+
if name.startswith(ENV_PREFIX):
|
|
170
|
+
path = [p.lower() for p in name[len(ENV_PREFIX) :].split(ENV_DELIMITER)]
|
|
171
|
+
if all(path):
|
|
172
|
+
_assign(tree, path, _parse_value(environ[name]), name)
|
|
173
|
+
for spec in _SECTIONS.values():
|
|
174
|
+
for alias, dotted in sorted(spec.env_aliases.items()):
|
|
175
|
+
value = environ.get(alias)
|
|
176
|
+
path = [spec.name, *dotted.split(".")]
|
|
177
|
+
if value and _lookup(tree, path) is None:
|
|
178
|
+
_assign(tree, path, value, alias)
|
|
179
|
+
return tree
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _lookup(tree: Mapping[str, Any], path: list[str]) -> Any:
|
|
183
|
+
node: Any = tree
|
|
184
|
+
for part in path:
|
|
185
|
+
if not isinstance(node, Mapping) or part not in node:
|
|
186
|
+
return None
|
|
187
|
+
node = node[part]
|
|
188
|
+
return node
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _EnvSource(PydanticBaseSettingsSource):
|
|
192
|
+
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
|
193
|
+
return None, field_name, False
|
|
194
|
+
|
|
195
|
+
def __call__(self) -> dict[str, Any]:
|
|
196
|
+
return environment_overrides(os.environ)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _YamlSource(PydanticBaseSettingsSource):
|
|
200
|
+
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
|
201
|
+
return None, field_name, False
|
|
202
|
+
|
|
203
|
+
def __call__(self) -> dict[str, Any]:
|
|
204
|
+
location = os.environ.get(CONFIG_ENV_VAR)
|
|
205
|
+
if not location:
|
|
206
|
+
return {}
|
|
207
|
+
path = Path(location)
|
|
208
|
+
try:
|
|
209
|
+
content = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise ConfigError(
|
|
212
|
+
[f"{CONFIG_ENV_VAR}: cannot read {location!r}: {exc.strerror}"]
|
|
213
|
+
) from None
|
|
214
|
+
except yaml.YAMLError as exc:
|
|
215
|
+
raise ConfigError([f"{location}: invalid YAML: {exc}"]) from None
|
|
216
|
+
if content is None:
|
|
217
|
+
return {}
|
|
218
|
+
if not isinstance(content, dict):
|
|
219
|
+
raise ConfigError([f"{location}: top level must be a mapping"])
|
|
220
|
+
return {str(key): value for key, value in content.items()}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Settings(BaseSettings):
|
|
224
|
+
"""Process-wide settings. Build with `load_settings`."""
|
|
225
|
+
|
|
226
|
+
model_config = SettingsConfigDict(extra="allow", frozen=True)
|
|
227
|
+
|
|
228
|
+
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
|
229
|
+
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def settings_customise_sources(
|
|
233
|
+
cls,
|
|
234
|
+
settings_cls: type[BaseSettings],
|
|
235
|
+
init_settings: PydanticBaseSettingsSource,
|
|
236
|
+
env_settings: PydanticBaseSettingsSource,
|
|
237
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
238
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
239
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
240
|
+
return (init_settings, _EnvSource(settings_cls), _YamlSource(settings_cls))
|
|
241
|
+
|
|
242
|
+
def section(self, name: str, model: type[SectionT]) -> SectionT:
|
|
243
|
+
"""Validated settings section declared by another package."""
|
|
244
|
+
raw = (self.model_extra or {}).get(name) or {}
|
|
245
|
+
try:
|
|
246
|
+
return model.model_validate(raw)
|
|
247
|
+
except ValidationError as exc:
|
|
248
|
+
raise ConfigError.from_validation(exc, prefix=name) from None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def load_settings() -> Settings:
|
|
252
|
+
"""Load and fully validate settings; raise `ConfigError` listing every problem."""
|
|
253
|
+
try:
|
|
254
|
+
settings = Settings()
|
|
255
|
+
except ValidationError as exc:
|
|
256
|
+
raise ConfigError.from_validation(exc) from None
|
|
257
|
+
problems: list[str] = []
|
|
258
|
+
for spec in _SECTIONS.values():
|
|
259
|
+
try:
|
|
260
|
+
settings.section(spec.name, spec.model)
|
|
261
|
+
except ConfigError as exc:
|
|
262
|
+
problems.extend(exc.problems)
|
|
263
|
+
if problems:
|
|
264
|
+
raise ConfigError(problems)
|
|
265
|
+
return settings
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""OpenTelemetry setup for traces and metrics.
|
|
2
|
+
|
|
3
|
+
Telemetry is off unless `telemetry.otlp_endpoint` is configured: no provider, no exporter, no
|
|
4
|
+
background thread and no network access are created in that case.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from opentelemetry import metrics, trace
|
|
12
|
+
from opentelemetry.sdk.metrics import MeterProvider
|
|
13
|
+
from opentelemetry.sdk.metrics.export import MetricReader, PeriodicExportingMetricReader
|
|
14
|
+
from opentelemetry.sdk.resources import Resource
|
|
15
|
+
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
|
|
16
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
|
|
17
|
+
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
|
|
18
|
+
|
|
19
|
+
from lorebase_platform.settings import TelemetrySettings
|
|
20
|
+
|
|
21
|
+
LATENCY_BUCKETS_S: tuple[float, ...] = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10)
|
|
22
|
+
"""Bucket boundaries, in seconds, of the duration histograms (request, job and commit
|
|
23
|
+
latency). Without them the SDK defaults (0, 5, 10, 25, ...) put every sub-5-second value in
|
|
24
|
+
one bucket and quantiles cannot be read."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Telemetry:
|
|
29
|
+
"""Handle on the configured providers; `enabled` is False when nothing was set up."""
|
|
30
|
+
|
|
31
|
+
enabled: bool
|
|
32
|
+
tracer_provider: TracerProvider | None = None
|
|
33
|
+
meter_provider: MeterProvider | None = None
|
|
34
|
+
|
|
35
|
+
def shutdown(self) -> None:
|
|
36
|
+
"""Flush pending data and stop exporters."""
|
|
37
|
+
if self.tracer_provider is not None:
|
|
38
|
+
self.tracer_provider.shutdown()
|
|
39
|
+
if self.meter_provider is not None:
|
|
40
|
+
self.meter_provider.shutdown()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setup_telemetry(
|
|
44
|
+
settings: TelemetrySettings,
|
|
45
|
+
*,
|
|
46
|
+
install_global: bool = True,
|
|
47
|
+
span_exporter: SpanExporter | None = None,
|
|
48
|
+
metric_reader: MetricReader | None = None,
|
|
49
|
+
) -> Telemetry:
|
|
50
|
+
"""Create providers for the configured endpoint, or return a disabled handle.
|
|
51
|
+
|
|
52
|
+
`span_exporter` and `metric_reader` replace the OTLP exporters (used by tests and by
|
|
53
|
+
deployments that export through another channel). `install_global` registers the
|
|
54
|
+
providers with the OpenTelemetry API.
|
|
55
|
+
"""
|
|
56
|
+
if settings.otlp_endpoint is None:
|
|
57
|
+
return Telemetry(enabled=False)
|
|
58
|
+
|
|
59
|
+
resource = Resource.create(
|
|
60
|
+
{
|
|
61
|
+
"service.name": settings.service_name,
|
|
62
|
+
"service.namespace": settings.service_namespace,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
tracer_provider = TracerProvider(
|
|
66
|
+
resource=resource, sampler=ParentBased(TraceIdRatioBased(settings.sample_ratio))
|
|
67
|
+
)
|
|
68
|
+
tracer_provider.add_span_processor(_span_processor(settings, span_exporter))
|
|
69
|
+
meter_provider = MeterProvider(
|
|
70
|
+
resource=resource, metric_readers=[metric_reader or _metric_reader(settings)]
|
|
71
|
+
)
|
|
72
|
+
if install_global:
|
|
73
|
+
trace.set_tracer_provider(tracer_provider)
|
|
74
|
+
metrics.set_meter_provider(meter_provider)
|
|
75
|
+
return Telemetry(enabled=True, tracer_provider=tracer_provider, meter_provider=meter_provider)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _span_processor(settings: TelemetrySettings, exporter: SpanExporter | None) -> SpanProcessor:
|
|
79
|
+
if exporter is None:
|
|
80
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
81
|
+
|
|
82
|
+
exporter = OTLPSpanExporter(endpoint=settings.otlp_endpoint)
|
|
83
|
+
return BatchSpanProcessor(exporter)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _metric_reader(settings: TelemetrySettings) -> MetricReader:
|
|
87
|
+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
|
88
|
+
|
|
89
|
+
return PeriodicExportingMetricReader(
|
|
90
|
+
OTLPMetricExporter(endpoint=settings.otlp_endpoint),
|
|
91
|
+
export_interval_millis=settings.metric_export_interval_s * 1000,
|
|
92
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from lorebase_platform import secrets, settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(autouse=True)
|
|
11
|
+
def clean_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
12
|
+
"""Isolate each test from ambient configuration and from other tests' registrations."""
|
|
13
|
+
for name in list(os.environ):
|
|
14
|
+
if name.startswith(settings.ENV_PREFIX) or name == settings.CONFIG_ENV_VAR:
|
|
15
|
+
monkeypatch.delenv(name)
|
|
16
|
+
monkeypatch.setattr(settings, "_SECTIONS", dict(settings._SECTIONS))
|
|
17
|
+
monkeypatch.setattr(secrets, "_known_values", set())
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from pydantic import SecretStr
|
|
12
|
+
|
|
13
|
+
from lorebase_platform.logging import (
|
|
14
|
+
REDACTED,
|
|
15
|
+
configure_logging,
|
|
16
|
+
is_sensitive_key,
|
|
17
|
+
request_context,
|
|
18
|
+
)
|
|
19
|
+
from lorebase_platform.secrets import register_secret_value
|
|
20
|
+
from lorebase_platform.settings import LoggingSettings
|
|
21
|
+
|
|
22
|
+
SECRET = "hunter2-very-secret"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def stream() -> Iterator[io.StringIO]:
|
|
27
|
+
root = logging.getLogger()
|
|
28
|
+
saved_handlers, saved_level = list(root.handlers), root.level
|
|
29
|
+
buffer = io.StringIO()
|
|
30
|
+
configure_logging(LoggingSettings(level="DEBUG"), service="test-service", stream=buffer)
|
|
31
|
+
yield buffer
|
|
32
|
+
root.handlers[:] = saved_handlers
|
|
33
|
+
root.setLevel(saved_level)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def records(stream: io.StringIO) -> list[dict[str, Any]]:
|
|
37
|
+
return [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_emits_one_json_object_per_record(stream: io.StringIO) -> None:
|
|
41
|
+
logging.getLogger("demo").info("hello %s", "world", extra={"order": 3})
|
|
42
|
+
(record,) = records(stream)
|
|
43
|
+
assert record["message"] == "hello world"
|
|
44
|
+
assert record["level"] == "INFO"
|
|
45
|
+
assert record["logger"] == "demo"
|
|
46
|
+
assert record["service"] == "test-service"
|
|
47
|
+
assert record["order"] == 3
|
|
48
|
+
assert "trace_id" not in record
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_registered_secret_is_masked_in_message_args_and_extra(stream: io.StringIO) -> None:
|
|
52
|
+
register_secret_value(SECRET)
|
|
53
|
+
log = logging.getLogger("demo")
|
|
54
|
+
log.warning("token is %s", SECRET)
|
|
55
|
+
log.warning(f"inline {SECRET}")
|
|
56
|
+
log.warning("extra", extra={"detail": f"value={SECRET}", "nested": {"a": [SECRET]}})
|
|
57
|
+
output = stream.getvalue()
|
|
58
|
+
assert SECRET not in output
|
|
59
|
+
assert output.count(REDACTED) >= 4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_secret_in_exception_text_is_masked(stream: io.StringIO) -> None:
|
|
63
|
+
register_secret_value(SECRET)
|
|
64
|
+
try:
|
|
65
|
+
raise RuntimeError(f"connection failed for {SECRET}")
|
|
66
|
+
except RuntimeError:
|
|
67
|
+
logging.getLogger("demo").exception("boom")
|
|
68
|
+
(record,) = records(stream)
|
|
69
|
+
assert SECRET not in json.dumps(record)
|
|
70
|
+
assert "connection failed for ***" in record["exception"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_sensitive_field_names_are_masked_even_for_unknown_values(stream: io.StringIO) -> None:
|
|
74
|
+
logging.getLogger("demo").info(
|
|
75
|
+
"login",
|
|
76
|
+
extra={"password": "p4ss", "access_token": "abc", "input_tokens": 12, "user": "ann"},
|
|
77
|
+
)
|
|
78
|
+
(record,) = records(stream)
|
|
79
|
+
assert record["password"] == REDACTED
|
|
80
|
+
assert record["access_token"] == REDACTED
|
|
81
|
+
assert record["input_tokens"] == 12
|
|
82
|
+
assert record["user"] == "ann"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_secret_str_objects_are_masked(stream: io.StringIO) -> None:
|
|
86
|
+
logging.getLogger("demo").info("cfg", extra={"key": SecretStr(SECRET)})
|
|
87
|
+
assert SECRET not in stream.getvalue()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_short_secret_values_are_not_masked_in_free_text(stream: io.StringIO) -> None:
|
|
91
|
+
register_secret_value("ab")
|
|
92
|
+
logging.getLogger("demo").info("a cabinet")
|
|
93
|
+
assert records(stream)[0]["message"] == "a cabinet"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_trace_and_request_ids_are_attached(stream: io.StringIO) -> None:
|
|
97
|
+
tracer = TracerProvider().get_tracer("test")
|
|
98
|
+
with tracer.start_as_current_span("op") as span, request_context("req-1"):
|
|
99
|
+
logging.getLogger("demo").info("inside")
|
|
100
|
+
context = span.get_span_context()
|
|
101
|
+
(record,) = records(stream)
|
|
102
|
+
assert record["trace_id"] == f"{context.trace_id:032x}"
|
|
103
|
+
assert record["span_id"] == f"{context.span_id:016x}"
|
|
104
|
+
assert record["request_id"] == "req-1"
|
|
105
|
+
logging.getLogger("demo").info("outside")
|
|
106
|
+
assert "request_id" not in records(stream)[1]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_configure_logging_is_idempotent(stream: io.StringIO) -> None:
|
|
110
|
+
second = io.StringIO()
|
|
111
|
+
configure_logging(LoggingSettings(), service="again", stream=second)
|
|
112
|
+
logging.getLogger("demo").warning("once")
|
|
113
|
+
assert stream.getvalue() == ""
|
|
114
|
+
assert len(second.getvalue().splitlines()) == 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_text_format_keeps_request_id() -> None:
|
|
118
|
+
root = logging.getLogger()
|
|
119
|
+
saved_handlers, saved_level = list(root.handlers), root.level
|
|
120
|
+
buffer = io.StringIO()
|
|
121
|
+
try:
|
|
122
|
+
configure_logging(LoggingSettings(format="text"), service="svc", stream=buffer)
|
|
123
|
+
with request_context("req-9"):
|
|
124
|
+
logging.getLogger("demo").warning("plain")
|
|
125
|
+
finally:
|
|
126
|
+
root.handlers[:] = saved_handlers
|
|
127
|
+
root.setLevel(saved_level)
|
|
128
|
+
assert "plain" in buffer.getvalue()
|
|
129
|
+
assert "request_id=req-9" in buffer.getvalue()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.parametrize(
|
|
133
|
+
("key", "expected"),
|
|
134
|
+
[
|
|
135
|
+
("password", True),
|
|
136
|
+
("api_key", True),
|
|
137
|
+
("X-Api-Key", True),
|
|
138
|
+
("access_token", True),
|
|
139
|
+
("input_tokens", False),
|
|
140
|
+
("monkey", False),
|
|
141
|
+
("secretary", False),
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
def test_sensitive_key_matching(key: str, expected: bool) -> None:
|
|
145
|
+
keys = ("password", "secret", "token", "api_key")
|
|
146
|
+
assert is_sensitive_key(key, keys) is expected
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import SecretStr
|
|
7
|
+
|
|
8
|
+
from lorebase_platform.secrets import (
|
|
9
|
+
SecretResolutionError,
|
|
10
|
+
SecretValue,
|
|
11
|
+
registered_secret_values,
|
|
12
|
+
resolve_secret,
|
|
13
|
+
)
|
|
14
|
+
from lorebase_platform.settings import (
|
|
15
|
+
CONFIG_ENV_VAR,
|
|
16
|
+
ConfigError,
|
|
17
|
+
SectionModel,
|
|
18
|
+
load_settings,
|
|
19
|
+
register_section,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Holder(SectionModel):
|
|
24
|
+
password: SecretValue
|
|
25
|
+
optional: SecretValue | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_env_reference_resolves(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
29
|
+
monkeypatch.setenv("DEMO_PASSWORD", "s3cr3t-value")
|
|
30
|
+
assert resolve_secret("secret://env/DEMO_PASSWORD") == "s3cr3t-value"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_file_reference_resolves_and_strips_newline(tmp_path: Path) -> None:
|
|
34
|
+
path = tmp_path / "pw"
|
|
35
|
+
path.write_text("file-secret\n")
|
|
36
|
+
assert resolve_secret(f"secret://file/{path}") == "file-secret"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_literal_value_is_returned_unchanged() -> None:
|
|
40
|
+
assert resolve_secret("plain-literal") == "plain-literal"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_missing_environment_variable_is_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
44
|
+
monkeypatch.delenv("DEMO_MISSING", raising=False)
|
|
45
|
+
with pytest.raises(SecretResolutionError, match=r"secret://env/DEMO_MISSING.*not set"):
|
|
46
|
+
resolve_secret("secret://env/DEMO_MISSING")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_missing_file_is_a_clear_error() -> None:
|
|
50
|
+
with pytest.raises(SecretResolutionError, match="cannot read secret file"):
|
|
51
|
+
resolve_secret("secret://file//nonexistent/secret")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_unsupported_provider_and_malformed_reference() -> None:
|
|
55
|
+
with pytest.raises(SecretResolutionError, match="unsupported secret provider 'vault'"):
|
|
56
|
+
resolve_secret("secret://vault/path")
|
|
57
|
+
with pytest.raises(SecretResolutionError, match="malformed"):
|
|
58
|
+
resolve_secret("secret://env")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_empty_secret_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
62
|
+
monkeypatch.setenv("DEMO_EMPTY", "")
|
|
63
|
+
with pytest.raises(SecretResolutionError, match="empty"):
|
|
64
|
+
resolve_secret("secret://env/DEMO_EMPTY")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_secret_fields_hide_values_in_repr_and_str(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
68
|
+
monkeypatch.setenv("DEMO_PASSWORD", "s3cr3t-value")
|
|
69
|
+
holder = Holder(password="secret://env/DEMO_PASSWORD")
|
|
70
|
+
assert isinstance(holder.password, SecretStr)
|
|
71
|
+
assert holder.password.get_secret_value() == "s3cr3t-value"
|
|
72
|
+
assert "s3cr3t-value" not in repr(holder)
|
|
73
|
+
assert "s3cr3t-value" not in str(holder)
|
|
74
|
+
assert "s3cr3t-value" not in holder.model_dump_json()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_resolved_values_are_registered_for_redaction(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
78
|
+
monkeypatch.setenv("DEMO_PASSWORD", "s3cr3t-value")
|
|
79
|
+
Holder(password="secret://env/DEMO_PASSWORD")
|
|
80
|
+
assert "s3cr3t-value" in registered_secret_values()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_missing_reference_in_settings_names_key_and_reference(
|
|
84
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
85
|
+
) -> None:
|
|
86
|
+
register_section("holder", Holder)
|
|
87
|
+
config = tmp_path / "c.yaml"
|
|
88
|
+
config.write_text("holder:\n password: secret://env/DEMO_ABSENT\n")
|
|
89
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(config))
|
|
90
|
+
monkeypatch.delenv("DEMO_ABSENT", raising=False)
|
|
91
|
+
with pytest.raises(ConfigError) as caught:
|
|
92
|
+
load_settings()
|
|
93
|
+
assert "holder.password" in str(caught.value)
|
|
94
|
+
assert "secret://env/DEMO_ABSENT" in str(caught.value)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_errors_never_contain_secret_values(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
98
|
+
register_section("holder", Holder)
|
|
99
|
+
monkeypatch.setenv("LOREBASE__HOLDER__PASSWORD", "super-secret-literal")
|
|
100
|
+
monkeypatch.setenv("LOREBASE__HOLDER__OPTIONAL", "[1, 2]")
|
|
101
|
+
with pytest.raises(ConfigError) as caught:
|
|
102
|
+
load_settings()
|
|
103
|
+
assert "super-secret-literal" not in str(caught.value)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
|
|
8
|
+
from lorebase_platform.secrets import SecretValue
|
|
9
|
+
from lorebase_platform.settings import (
|
|
10
|
+
CONFIG_ENV_VAR,
|
|
11
|
+
ConfigError,
|
|
12
|
+
SectionModel,
|
|
13
|
+
Settings,
|
|
14
|
+
load_settings,
|
|
15
|
+
register_section,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
EXAMPLE = Path(__file__).resolve().parents[3] / "config" / "lorebase.example.yaml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DemoSection(SectionModel):
|
|
22
|
+
count: int = 1
|
|
23
|
+
name: str = "base"
|
|
24
|
+
token: SecretValue | None = None
|
|
25
|
+
tags: list[str] = Field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_yaml(tmp_path: Path, text: str) -> Path:
|
|
29
|
+
path = tmp_path / "config.yaml"
|
|
30
|
+
path.write_text(text)
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_defaults_apply_without_any_source() -> None:
|
|
35
|
+
settings = load_settings()
|
|
36
|
+
assert settings.telemetry.otlp_endpoint is None
|
|
37
|
+
assert settings.telemetry.sample_ratio == 0.1
|
|
38
|
+
assert settings.logging.level == "INFO"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_yaml_overrides_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
42
|
+
path = write_yaml(tmp_path, "telemetry:\n sample_ratio: 0.5\n service_name: from-yaml\n")
|
|
43
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(path))
|
|
44
|
+
settings = load_settings()
|
|
45
|
+
assert settings.telemetry.sample_ratio == 0.5
|
|
46
|
+
assert settings.telemetry.service_name == "from-yaml"
|
|
47
|
+
assert settings.telemetry.service_namespace == "lorebase"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_env_overrides_yaml_overrides_defaults(
|
|
51
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
52
|
+
) -> None:
|
|
53
|
+
path = write_yaml(tmp_path, "telemetry:\n sample_ratio: 0.5\n service_name: from-yaml\n")
|
|
54
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(path))
|
|
55
|
+
monkeypatch.setenv("LOREBASE__TELEMETRY__SAMPLE_RATIO", "0.25")
|
|
56
|
+
settings = load_settings()
|
|
57
|
+
assert settings.telemetry.sample_ratio == 0.25 # env wins
|
|
58
|
+
assert settings.telemetry.service_name == "from-yaml" # yaml wins over default
|
|
59
|
+
assert settings.telemetry.service_namespace == "lorebase" # default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_env_accepts_json_for_lists(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
63
|
+
monkeypatch.setenv("LOREBASE__LOGGING__REDACT_KEYS", '["pin", "otp"]')
|
|
64
|
+
assert load_settings().logging.redact_keys == ("pin", "otp")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_empty_endpoint_means_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
68
|
+
monkeypatch.setenv("LOREBASE__TELEMETRY__OTLP_ENDPOINT", " ")
|
|
69
|
+
assert load_settings().telemetry.otlp_endpoint is None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_invalid_value_names_key_and_reason(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
73
|
+
monkeypatch.setenv("LOREBASE__TELEMETRY__SAMPLE_RATIO", "3")
|
|
74
|
+
monkeypatch.setenv("LOREBASE__LOGGING__LEVEL", "LOUD")
|
|
75
|
+
with pytest.raises(ConfigError) as caught:
|
|
76
|
+
load_settings()
|
|
77
|
+
message = str(caught.value)
|
|
78
|
+
assert "telemetry.sample_ratio: Input should be less than or equal to 1" in message
|
|
79
|
+
assert "logging.level" in message
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_unknown_key_in_owned_section_is_rejected(
|
|
83
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
84
|
+
) -> None:
|
|
85
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(write_yaml(tmp_path, "telemetry:\n endpoint: x\n")))
|
|
86
|
+
with pytest.raises(ConfigError, match=r"telemetry\.endpoint: Extra inputs"):
|
|
87
|
+
load_settings()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_missing_config_file_fails_clearly(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
91
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, "/nonexistent/lorebase.yaml")
|
|
92
|
+
with pytest.raises(ConfigError, match=CONFIG_ENV_VAR):
|
|
93
|
+
load_settings()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_yaml_must_be_a_mapping(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
97
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(write_yaml(tmp_path, "- a\n- b\n")))
|
|
98
|
+
with pytest.raises(ConfigError, match="top level must be a mapping"):
|
|
99
|
+
load_settings()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_registered_section_is_layered_and_validated(
|
|
103
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
104
|
+
) -> None:
|
|
105
|
+
register_section("demo", DemoSection)
|
|
106
|
+
path = write_yaml(tmp_path, "demo:\n count: 5\n name: yaml\n tags: [a]\n")
|
|
107
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(path))
|
|
108
|
+
monkeypatch.setenv("LOREBASE__DEMO__COUNT", "7")
|
|
109
|
+
section = load_settings().section("demo", DemoSection)
|
|
110
|
+
assert (section.count, section.name, section.tags) == (7, "yaml", ["a"])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_registered_section_errors_use_the_section_prefix(
|
|
114
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
115
|
+
) -> None:
|
|
116
|
+
register_section("demo", DemoSection)
|
|
117
|
+
monkeypatch.setenv("LOREBASE__DEMO__COUNT", "many")
|
|
118
|
+
with pytest.raises(ConfigError, match=r"demo\.count"):
|
|
119
|
+
load_settings()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_unregistered_sections_are_tolerated(
|
|
123
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
124
|
+
) -> None:
|
|
125
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(write_yaml(tmp_path, "database:\n pool_size: 3\n")))
|
|
126
|
+
assert load_settings().model_extra == {"database": {"pool_size": 3}}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_env_aliases_override_yaml_but_not_explicit_env(
|
|
130
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
131
|
+
) -> None:
|
|
132
|
+
register_section("demo", DemoSection, env_aliases={"DEMO_NAME": "name", "DEMO_TOKEN": "token"})
|
|
133
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(write_yaml(tmp_path, "demo:\n name: yaml\n")))
|
|
134
|
+
monkeypatch.setenv("DEMO_NAME", "alias")
|
|
135
|
+
monkeypatch.setenv("DEMO_TOKEN", "")
|
|
136
|
+
assert load_settings().section("demo", DemoSection).name == "alias"
|
|
137
|
+
assert load_settings().section("demo", DemoSection).token is None # empty alias ignored
|
|
138
|
+
monkeypatch.setenv("LOREBASE__DEMO__NAME", "explicit")
|
|
139
|
+
assert load_settings().section("demo", DemoSection).name == "explicit"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_conflicting_env_keys_fail(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
143
|
+
monkeypatch.setenv("LOREBASE__DEMO", "x")
|
|
144
|
+
monkeypatch.setenv("LOREBASE__DEMO__COUNT", "1")
|
|
145
|
+
with pytest.raises(ConfigError, match="conflicts"):
|
|
146
|
+
load_settings()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_registering_a_platform_section_name_is_refused() -> None:
|
|
150
|
+
with pytest.raises(ValueError, match="owned by lorebase_platform"):
|
|
151
|
+
register_section("telemetry", DemoSection)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_example_file_loads_and_matches_code_defaults(
|
|
155
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
156
|
+
) -> None:
|
|
157
|
+
defaults = load_settings()
|
|
158
|
+
monkeypatch.setenv(CONFIG_ENV_VAR, str(EXAMPLE))
|
|
159
|
+
from_example = load_settings()
|
|
160
|
+
assert from_example.telemetry == defaults.telemetry
|
|
161
|
+
assert from_example.logging == defaults.logging
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_settings_is_immutable() -> None:
|
|
165
|
+
settings = load_settings()
|
|
166
|
+
with pytest.raises(ValueError, match="frozen"):
|
|
167
|
+
settings.telemetry = settings.telemetry.model_copy()
|
|
168
|
+
assert isinstance(settings, Settings)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from opentelemetry import trace
|
|
7
|
+
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
|
8
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
9
|
+
|
|
10
|
+
from lorebase_platform.settings import TelemetrySettings
|
|
11
|
+
from lorebase_platform.telemetry import setup_telemetry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_no_endpoint_creates_no_exporter_and_no_network_call(
|
|
15
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
16
|
+
) -> None:
|
|
17
|
+
def fail(*_args: object, **_kwargs: object) -> None:
|
|
18
|
+
raise AssertionError("telemetry setup must not touch the network")
|
|
19
|
+
|
|
20
|
+
monkeypatch.setattr(socket.socket, "connect", fail)
|
|
21
|
+
monkeypatch.setattr(socket, "getaddrinfo", fail)
|
|
22
|
+
previous = trace.get_tracer_provider()
|
|
23
|
+
|
|
24
|
+
handle = setup_telemetry(TelemetrySettings())
|
|
25
|
+
handle.shutdown()
|
|
26
|
+
|
|
27
|
+
assert handle.enabled is False
|
|
28
|
+
assert handle.tracer_provider is None
|
|
29
|
+
assert handle.meter_provider is None
|
|
30
|
+
assert trace.get_tracer_provider() is previous
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_empty_endpoint_is_treated_as_disabled() -> None:
|
|
34
|
+
assert setup_telemetry(TelemetrySettings(otlp_endpoint="")).enabled is False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_endpoint_enables_providers_with_resource_and_sampling() -> None:
|
|
38
|
+
spans = InMemorySpanExporter()
|
|
39
|
+
reader = InMemoryMetricReader()
|
|
40
|
+
settings = TelemetrySettings(
|
|
41
|
+
otlp_endpoint="http://collector:4317",
|
|
42
|
+
service_name="svc",
|
|
43
|
+
service_namespace="ns",
|
|
44
|
+
sample_ratio=1.0,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
handle = setup_telemetry(
|
|
48
|
+
settings, install_global=False, span_exporter=spans, metric_reader=reader
|
|
49
|
+
)
|
|
50
|
+
assert handle.enabled
|
|
51
|
+
assert handle.tracer_provider is not None
|
|
52
|
+
assert handle.meter_provider is not None
|
|
53
|
+
|
|
54
|
+
with handle.tracer_provider.get_tracer("t").start_as_current_span("op"):
|
|
55
|
+
pass
|
|
56
|
+
handle.meter_provider.get_meter("m").create_counter("hits").add(2)
|
|
57
|
+
handle.tracer_provider.force_flush()
|
|
58
|
+
|
|
59
|
+
(span,) = spans.get_finished_spans()
|
|
60
|
+
assert span.name == "op"
|
|
61
|
+
assert span.resource.attributes["service.name"] == "svc"
|
|
62
|
+
assert span.resource.attributes["service.namespace"] == "ns"
|
|
63
|
+
assert reader.get_metrics_data() is not None
|
|
64
|
+
handle.shutdown()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_zero_sample_ratio_drops_root_spans() -> None:
|
|
68
|
+
spans = InMemorySpanExporter()
|
|
69
|
+
handle = setup_telemetry(
|
|
70
|
+
TelemetrySettings(otlp_endpoint="http://collector:4317", sample_ratio=0),
|
|
71
|
+
install_global=False,
|
|
72
|
+
span_exporter=spans,
|
|
73
|
+
metric_reader=InMemoryMetricReader(),
|
|
74
|
+
)
|
|
75
|
+
assert handle.tracer_provider is not None
|
|
76
|
+
with handle.tracer_provider.get_tracer("t").start_as_current_span("op"):
|
|
77
|
+
pass
|
|
78
|
+
handle.tracer_provider.force_flush()
|
|
79
|
+
assert spans.get_finished_spans() == ()
|
|
80
|
+
handle.shutdown()
|