lorebase-platform 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
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,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,8 @@
1
+ lorebase_platform/__init__.py,sha256=PNiDER4qM19h9zdsdfgKt2_dT4WgYK7EguJ8RU2qA_g,22
2
+ lorebase_platform/logging.py,sha256=I9Kicx-svmty8Y8cTKKKYaIGOQy1KqiifDuXdIwXcYw,6213
3
+ lorebase_platform/secrets.py,sha256=mG8j65rqhVROWwSIc2kXXBKnUJv-bpXk5gR1Fw0-wnA,3370
4
+ lorebase_platform/settings.py,sha256=JVHQguo-t-WTq5pZRuux3DKz2Dw-BeZJxaC7krg_RV0,9950
5
+ lorebase_platform/telemetry.py,sha256=yLsE1NdtpnZpnESHP-FoijSZ4oSTQehA_FlnBkB8eGA,3605
6
+ lorebase_platform-0.2.3.dist-info/METADATA,sha256=E0bS4Oh_gyJHSyt-mIA2YqPFvnM4PFcIDsAjDwoSk5k,386
7
+ lorebase_platform-0.2.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ lorebase_platform-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any