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.
@@ -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()