botanu 0.1.dev60__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.
botanu/sdk/config.py ADDED
@@ -0,0 +1,330 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Configuration for Botanu SDK.
5
+
6
+ The SDK is intentionally minimal on the hot path. Heavy processing happens in
7
+ the OpenTelemetry Collector, not in the application:
8
+
9
+ - **SDK responsibility**: Generate run_id, propagate minimal context (run_id, workflow)
10
+ - **Collector responsibility**: PII redaction, vendor detection, attribute enrichment
11
+
12
+ Configuration precedence (highest to lowest):
13
+ 1. Code arguments (explicit values passed to BotanuConfig)
14
+ 2. Environment variables (BOTANU_*, OTEL_*)
15
+ 3. YAML config file (botanu.yaml or specified path)
16
+ 4. Built-in defaults
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class BotanuConfig:
33
+ """Configuration for Botanu SDK and OpenTelemetry.
34
+
35
+ The SDK is a thin wrapper on OpenTelemetry. PII redaction, cardinality
36
+ limits, and vendor enrichment are handled by the OTel Collector — not here.
37
+
38
+ Typically configured via environment variables (no hardcoded values)::
39
+
40
+ >>> # Reads from OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, etc.
41
+ >>> config = BotanuConfig()
42
+
43
+ >>> # Or load from YAML
44
+ >>> config = BotanuConfig.from_yaml("config/botanu.yaml")
45
+ """
46
+
47
+ # Service identification
48
+ service_name: Optional[str] = None
49
+ service_version: Optional[str] = None
50
+ service_namespace: Optional[str] = None
51
+ deployment_environment: Optional[str] = None
52
+
53
+ # Resource detection
54
+ auto_detect_resources: bool = True
55
+
56
+ # OTLP exporter configuration
57
+ otlp_endpoint: Optional[str] = None
58
+ otlp_headers: Optional[Dict[str, str]] = None
59
+
60
+ # Span export configuration
61
+ # Large queue prevents span loss under burst traffic.
62
+ # At ~1KB/span, 65536 spans ≈ 64MB memory ceiling.
63
+ max_export_batch_size: int = 512
64
+ max_queue_size: int = 65536
65
+ schedule_delay_millis: int = 5000
66
+ export_timeout_millis: int = 30000
67
+
68
+ # Propagation mode: "lean" (run_id + workflow only) or "full" (all context)
69
+ propagation_mode: str = "lean"
70
+
71
+ # Auto-instrumentation packages to enable
72
+ auto_instrument_packages: List[str] = field(
73
+ default_factory=lambda: [
74
+ # HTTP clients
75
+ "requests",
76
+ "httpx",
77
+ "urllib3",
78
+ "aiohttp_client",
79
+ # Web frameworks
80
+ "fastapi",
81
+ "flask",
82
+ "django",
83
+ "starlette",
84
+ # Databases
85
+ "sqlalchemy",
86
+ "psycopg2",
87
+ "asyncpg",
88
+ "pymongo",
89
+ "redis",
90
+ # Messaging
91
+ "celery",
92
+ "kafka_python",
93
+ # gRPC
94
+ "grpc",
95
+ # GenAI / AI
96
+ "openai_v2",
97
+ "anthropic",
98
+ "vertexai",
99
+ "google_genai",
100
+ "langchain",
101
+ # Runtime
102
+ "logging",
103
+ ]
104
+ )
105
+
106
+ # Config file path (for tracking where config was loaded from)
107
+ _config_file: Optional[str] = field(default=None, repr=False)
108
+
109
+ def __post_init__(self) -> None:
110
+ """Apply environment variable defaults.
111
+
112
+ Precedence: BOTANU_* > OTEL_* > defaults
113
+ """
114
+ if self.service_name is None:
115
+ self.service_name = os.getenv(
116
+ "BOTANU_SERVICE_NAME",
117
+ os.getenv("OTEL_SERVICE_NAME", "unknown_service"),
118
+ )
119
+
120
+ if self.service_version is None:
121
+ self.service_version = os.getenv("OTEL_SERVICE_VERSION")
122
+
123
+ if self.service_namespace is None:
124
+ self.service_namespace = os.getenv("OTEL_SERVICE_NAMESPACE")
125
+
126
+ env_auto_detect = os.getenv("BOTANU_AUTO_DETECT_RESOURCES")
127
+ if env_auto_detect is not None:
128
+ self.auto_detect_resources = env_auto_detect.lower() in ("true", "1", "yes")
129
+
130
+ if self.deployment_environment is None:
131
+ self.deployment_environment = os.getenv(
132
+ "BOTANU_ENVIRONMENT",
133
+ os.getenv("OTEL_DEPLOYMENT_ENVIRONMENT", "production"),
134
+ )
135
+
136
+ if self.otlp_endpoint is None:
137
+ # Check BOTANU_COLLECTOR_ENDPOINT first, then OTEL_* vars
138
+ botanu_endpoint = os.getenv("BOTANU_COLLECTOR_ENDPOINT")
139
+ if botanu_endpoint:
140
+ self.otlp_endpoint = botanu_endpoint
141
+ else:
142
+ env_endpoint = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
143
+ if env_endpoint:
144
+ self.otlp_endpoint = env_endpoint
145
+ else:
146
+ base = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
147
+ self.otlp_endpoint = base
148
+
149
+ env_propagation_mode = os.getenv("BOTANU_PROPAGATION_MODE")
150
+ if env_propagation_mode and env_propagation_mode in ("lean", "full"):
151
+ self.propagation_mode = env_propagation_mode
152
+
153
+ # Export tuning via env vars
154
+ env_queue_size = os.getenv("BOTANU_MAX_QUEUE_SIZE")
155
+ if env_queue_size:
156
+ try:
157
+ self.max_queue_size = int(env_queue_size)
158
+ except ValueError:
159
+ pass
160
+
161
+ env_batch_size = os.getenv("BOTANU_MAX_EXPORT_BATCH_SIZE")
162
+ if env_batch_size:
163
+ try:
164
+ self.max_export_batch_size = int(env_batch_size)
165
+ except ValueError:
166
+ pass
167
+
168
+ env_export_timeout = os.getenv("BOTANU_EXPORT_TIMEOUT_MILLIS")
169
+ if env_export_timeout:
170
+ try:
171
+ self.export_timeout_millis = int(env_export_timeout)
172
+ except ValueError:
173
+ pass
174
+
175
+ # ------------------------------------------------------------------
176
+ # YAML loading
177
+ # ------------------------------------------------------------------
178
+
179
+ @classmethod
180
+ def from_yaml(cls, path: Optional[str] = None) -> BotanuConfig:
181
+ """Load configuration from a YAML file.
182
+
183
+ Supports environment variable interpolation using ``${VAR_NAME}`` syntax.
184
+
185
+ Args:
186
+ path: Path to YAML config file.
187
+
188
+ Raises:
189
+ FileNotFoundError: If config file doesn't exist.
190
+ ValueError: If YAML is malformed.
191
+ """
192
+ if path is None:
193
+ raise FileNotFoundError("No config file path provided")
194
+
195
+ resolved = Path(path)
196
+ if not resolved.exists():
197
+ raise FileNotFoundError(f"Config file not found: {resolved}")
198
+
199
+ try:
200
+ import yaml # type: ignore[import-untyped]
201
+ except ImportError as err:
202
+ raise ImportError("PyYAML required for YAML config. Install with: pip install pyyaml") from err
203
+
204
+ with open(resolved) as fh:
205
+ raw_content = fh.read()
206
+
207
+ content = _interpolate_env_vars(raw_content)
208
+
209
+ try:
210
+ data = yaml.safe_load(content)
211
+ except yaml.YAMLError as exc:
212
+ raise ValueError(f"Invalid YAML in {resolved}: {exc}") from exc
213
+
214
+ if data is None:
215
+ data = {}
216
+
217
+ return cls._from_dict(data, config_file=str(resolved))
218
+
219
+ @classmethod
220
+ def from_file_or_env(cls, path: Optional[str] = None) -> BotanuConfig:
221
+ """Load config from file if exists, otherwise use environment variables.
222
+
223
+ Search order:
224
+ 1. Explicit *path* argument
225
+ 2. ``BOTANU_CONFIG_FILE`` env var
226
+ 3. ``./botanu.yaml``
227
+ 4. ``./config/botanu.yaml``
228
+ 5. Falls back to env-only config
229
+ """
230
+ search_paths: List[Path] = []
231
+
232
+ if path:
233
+ search_paths.append(Path(path))
234
+
235
+ env_path = os.getenv("BOTANU_CONFIG_FILE")
236
+ if env_path:
237
+ search_paths.append(Path(env_path))
238
+
239
+ search_paths.extend(
240
+ [
241
+ Path("botanu.yaml"),
242
+ Path("botanu.yml"),
243
+ Path("config/botanu.yaml"),
244
+ Path("config/botanu.yml"),
245
+ ]
246
+ )
247
+
248
+ for candidate in search_paths:
249
+ if candidate.exists():
250
+ logger.info("Loading config from: %s", candidate)
251
+ return cls.from_yaml(str(candidate))
252
+
253
+ logger.debug("No config file found, using environment variables only")
254
+ return cls()
255
+
256
+ @classmethod
257
+ def _from_dict(
258
+ cls,
259
+ data: Dict[str, Any],
260
+ config_file: Optional[str] = None,
261
+ ) -> BotanuConfig:
262
+ """Create config from dictionary (parsed YAML)."""
263
+ service = data.get("service", {})
264
+ otlp = data.get("otlp", {})
265
+ export = data.get("export", {})
266
+ propagation = data.get("propagation", {})
267
+ resource = data.get("resource", {})
268
+ auto_packages = data.get("auto_instrument_packages")
269
+
270
+ return cls(
271
+ service_name=service.get("name"),
272
+ service_version=service.get("version"),
273
+ service_namespace=service.get("namespace"),
274
+ deployment_environment=service.get("environment"),
275
+ auto_detect_resources=resource.get("auto_detect", True),
276
+ otlp_endpoint=otlp.get("endpoint"),
277
+ otlp_headers=otlp.get("headers"),
278
+ max_export_batch_size=export.get("batch_size", 512),
279
+ max_queue_size=export.get("queue_size", 65536),
280
+ schedule_delay_millis=export.get("delay_ms", 5000),
281
+ export_timeout_millis=export.get("export_timeout_ms", 30000),
282
+ propagation_mode=propagation.get("mode", "lean"),
283
+ auto_instrument_packages=(auto_packages if auto_packages else BotanuConfig().auto_instrument_packages),
284
+ _config_file=config_file,
285
+ )
286
+
287
+ def to_dict(self) -> Dict[str, Any]:
288
+ """Export configuration as dictionary."""
289
+ return {
290
+ "service": {
291
+ "name": self.service_name,
292
+ "version": self.service_version,
293
+ "namespace": self.service_namespace,
294
+ "environment": self.deployment_environment,
295
+ },
296
+ "resource": {
297
+ "auto_detect": self.auto_detect_resources,
298
+ },
299
+ "otlp": {
300
+ "endpoint": self.otlp_endpoint,
301
+ "headers": self.otlp_headers,
302
+ },
303
+ "export": {
304
+ "batch_size": self.max_export_batch_size,
305
+ "queue_size": self.max_queue_size,
306
+ "delay_ms": self.schedule_delay_millis,
307
+ "export_timeout_ms": self.export_timeout_millis,
308
+ },
309
+ "propagation": {
310
+ "mode": self.propagation_mode,
311
+ },
312
+ "auto_instrument_packages": self.auto_instrument_packages,
313
+ }
314
+
315
+
316
+ def _interpolate_env_vars(content: str) -> str:
317
+ """Interpolate ``${VAR_NAME}`` and ``${VAR_NAME:-default}`` in *content*."""
318
+ pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
319
+
320
+ def _replace(match: re.Match) -> str: # type: ignore[type-arg]
321
+ var_name = match.group(1)
322
+ default = match.group(2)
323
+ value = os.getenv(var_name)
324
+ if value is not None:
325
+ return value
326
+ if default is not None:
327
+ return default
328
+ return match.group(0)
329
+
330
+ return pattern.sub(_replace, content)
botanu/sdk/context.py ADDED
@@ -0,0 +1,73 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Context and baggage helpers for Botanu SDK.
5
+
6
+ Uses OpenTelemetry Context and Baggage for propagation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional, cast
12
+
13
+ from opentelemetry import baggage, trace
14
+ from opentelemetry.context import attach, get_current
15
+
16
+
17
+ def set_baggage(key: str, value: str) -> object:
18
+ """Set a baggage value and attach the new context.
19
+
20
+ Baggage is automatically propagated across service boundaries via
21
+ W3C Baggage header.
22
+
23
+ .. warning::
24
+
25
+ Each call pushes a new context onto the stack. The returned token
26
+ **must** be passed to ``opentelemetry.context.detach()`` when the
27
+ scope ends, otherwise the context stack grows unboundedly (memory
28
+ leak in long-running processes).
29
+
30
+ For setting multiple keys, prefer building the context manually
31
+ and attaching once — see ``decorators.py`` for the pattern.
32
+
33
+ Args:
34
+ key: Baggage key (e.g., ``"botanu.run_id"``).
35
+ value: Baggage value.
36
+
37
+ Returns:
38
+ Token for detaching the context later.
39
+ """
40
+ ctx = baggage.set_baggage(key, value, context=get_current())
41
+ return attach(ctx)
42
+
43
+
44
+ def get_baggage(key: str) -> Optional[str]:
45
+ """Get a baggage value from the current context.
46
+
47
+ Args:
48
+ key: Baggage key (e.g., ``"botanu.run_id"``).
49
+
50
+ Returns:
51
+ Baggage value or ``None`` if not set.
52
+ """
53
+ value = baggage.get_baggage(key, context=get_current())
54
+ return cast(Optional[str], value)
55
+
56
+
57
+ def get_current_span() -> trace.Span:
58
+ """Get the current active span.
59
+
60
+ Returns:
61
+ Current span (may be non-recording if no span is active).
62
+ """
63
+ return trace.get_current_span()
64
+
65
+
66
+ def get_run_id() -> Optional[str]:
67
+ """Get the current ``run_id`` from baggage."""
68
+ return get_baggage("botanu.run_id")
69
+
70
+
71
+ def get_workflow() -> Optional[str]:
72
+ """Get the current ``workflow`` from baggage."""
73
+ return get_baggage("botanu.workflow")