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/__init__.py +76 -0
- botanu/_version.py +13 -0
- botanu/integrations/__init__.py +4 -0
- botanu/integrations/tenacity.py +60 -0
- botanu/models/__init__.py +10 -0
- botanu/models/run_context.py +328 -0
- botanu/processors/__init__.py +12 -0
- botanu/processors/enricher.py +84 -0
- botanu/py.typed +0 -0
- botanu/resources/__init__.py +87 -0
- botanu/sdk/__init__.py +37 -0
- botanu/sdk/bootstrap.py +405 -0
- botanu/sdk/config.py +330 -0
- botanu/sdk/context.py +73 -0
- botanu/sdk/decorators.py +407 -0
- botanu/sdk/middleware.py +97 -0
- botanu/sdk/span_helpers.py +143 -0
- botanu/tracking/__init__.py +55 -0
- botanu/tracking/data.py +488 -0
- botanu/tracking/llm.py +700 -0
- botanu-0.1.dev60.dist-info/METADATA +208 -0
- botanu-0.1.dev60.dist-info/RECORD +25 -0
- botanu-0.1.dev60.dist-info/WHEEL +4 -0
- botanu-0.1.dev60.dist-info/licenses/LICENSE +200 -0
- botanu-0.1.dev60.dist-info/licenses/NOTICE +17 -0
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")
|