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/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Botanu SDK core components."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from botanu.sdk.bootstrap import disable, enable, get_config, is_enabled
9
+ from botanu.sdk.config import BotanuConfig
10
+ from botanu.sdk.context import (
11
+ get_baggage,
12
+ get_current_span,
13
+ get_run_id,
14
+ get_workflow,
15
+ set_baggage,
16
+ )
17
+ from botanu.sdk.decorators import botanu_outcome, botanu_workflow, run_botanu, workflow
18
+ from botanu.sdk.span_helpers import emit_outcome, set_business_context
19
+
20
+ __all__ = [
21
+ "BotanuConfig",
22
+ "botanu_outcome",
23
+ "botanu_workflow",
24
+ "disable",
25
+ "emit_outcome",
26
+ "enable",
27
+ "get_baggage",
28
+ "get_config",
29
+ "get_current_span",
30
+ "get_run_id",
31
+ "get_workflow",
32
+ "is_enabled",
33
+ "run_botanu",
34
+ "set_baggage",
35
+ "set_business_context",
36
+ "workflow",
37
+ ]
@@ -0,0 +1,405 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Botanu Bootstrap — one-switch enablement for OTEL auto-instrumentation.
5
+
6
+ This is the "Botanu OTel Distribution" — a curated bundle that:
7
+
8
+ 1. Configures OTEL SDK with OTLP exporter
9
+ 2. Enables OTEL auto-instrumentation for popular libraries
10
+ 3. Adds :class:`~botanu.processors.enricher.RunContextEnricher`
11
+ (propagates ``run_id`` to all spans)
12
+ 4. Sets up W3C TraceContext + Baggage propagators
13
+
14
+ Usage::
15
+
16
+ from botanu import enable
17
+ enable() # reads OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT from env
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import threading
25
+ from typing import TYPE_CHECKING, List, Optional
26
+
27
+ if TYPE_CHECKING:
28
+ from botanu.sdk.config import BotanuConfig
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _lock = threading.RLock()
33
+ _initialized = False
34
+ _current_config: Optional[BotanuConfig] = None
35
+
36
+
37
+ def enable(
38
+ service_name: Optional[str] = None,
39
+ otlp_endpoint: Optional[str] = None,
40
+ environment: Optional[str] = None,
41
+ auto_instrumentation: bool = True,
42
+ propagators: Optional[List[str]] = None,
43
+ log_level: str = "INFO",
44
+ config: Optional[BotanuConfig] = None,
45
+ config_file: Optional[str] = None,
46
+ ) -> bool:
47
+ """Enable Botanu SDK with OTEL auto-instrumentation.
48
+
49
+ This is the ONE function customers need to call to get full observability.
50
+
51
+ Args:
52
+ service_name: Service name.
53
+ otlp_endpoint: OTLP collector endpoint.
54
+ environment: Deployment environment.
55
+ auto_instrumentation: Enable OTEL auto-instrumentation (default: ``True``).
56
+ propagators: List of propagators (default: ``["tracecontext", "baggage"]``).
57
+ log_level: Logging level (default: ``"INFO"``).
58
+ config: Full :class:`BotanuConfig` (overrides individual params).
59
+ config_file: Path to YAML config file.
60
+
61
+ Returns:
62
+ ``True`` if successfully initialized, ``False`` if already initialized.
63
+ """
64
+ global _initialized, _current_config
65
+
66
+ with _lock:
67
+ if _initialized:
68
+ logger.warning("Botanu SDK already initialized")
69
+ return False
70
+
71
+ logging.basicConfig(level=getattr(logging, log_level.upper()))
72
+
73
+ from botanu.sdk.config import BotanuConfig as ConfigClass
74
+
75
+ if config is not None:
76
+ cfg = config
77
+ elif config_file is not None:
78
+ cfg = ConfigClass.from_yaml(config_file)
79
+ else:
80
+ cfg = ConfigClass.from_file_or_env()
81
+
82
+ if service_name is not None:
83
+ cfg.service_name = service_name
84
+ if otlp_endpoint is not None:
85
+ cfg.otlp_endpoint = otlp_endpoint
86
+ if environment is not None:
87
+ cfg.deployment_environment = environment
88
+
89
+ _current_config = cfg
90
+
91
+ traces_endpoint = cfg.otlp_endpoint
92
+ if traces_endpoint and not traces_endpoint.endswith("/v1/traces"):
93
+ traces_endpoint = f"{traces_endpoint.rstrip('/')}/v1/traces"
94
+
95
+ otel_sampler_env = os.getenv("OTEL_TRACES_SAMPLER")
96
+ if otel_sampler_env and otel_sampler_env != "always_on":
97
+ logger.warning(
98
+ "OTEL_TRACES_SAMPLER=%s is set but Botanu enforces ALWAYS_ON. No spans will be sampled or dropped.",
99
+ otel_sampler_env,
100
+ )
101
+
102
+ logger.info(
103
+ "Initializing Botanu SDK: service=%s, env=%s, endpoint=%s",
104
+ cfg.service_name,
105
+ cfg.deployment_environment,
106
+ traces_endpoint,
107
+ )
108
+
109
+ try:
110
+ from opentelemetry import trace
111
+ from opentelemetry.baggage.propagation import W3CBaggagePropagator
112
+ from opentelemetry.propagate import set_global_textmap
113
+ from opentelemetry.propagators.composite import CompositePropagator
114
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
115
+ except ImportError as exc:
116
+ logger.error("Missing opentelemetry-api. Install with: pip install botanu")
117
+ raise ImportError("opentelemetry-api is required. Install with: pip install botanu") from exc
118
+
119
+ try:
120
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
121
+ from opentelemetry.sdk.resources import Resource
122
+ from opentelemetry.sdk.trace import TracerProvider
123
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
124
+ from opentelemetry.sdk.trace.sampling import ALWAYS_ON
125
+ except ImportError as exc:
126
+ logger.error("Missing OTel SDK dependencies. Install with: pip install botanu")
127
+ raise ImportError("OTel SDK and exporter required for enable(). Install with: pip install botanu") from exc
128
+
129
+ try:
130
+ from botanu._version import __version__
131
+ from botanu.processors import RunContextEnricher
132
+ from botanu.resources import detect_resource_attrs
133
+
134
+ resource_attrs = {
135
+ "service.name": cfg.service_name,
136
+ "deployment.environment": cfg.deployment_environment,
137
+ "telemetry.sdk.name": "botanu",
138
+ "telemetry.sdk.version": __version__,
139
+ }
140
+ if cfg.service_version:
141
+ resource_attrs["service.version"] = cfg.service_version
142
+ if cfg.service_namespace:
143
+ resource_attrs["service.namespace"] = cfg.service_namespace
144
+
145
+ if cfg.auto_detect_resources:
146
+ detected = detect_resource_attrs()
147
+ for key, value in detected.items():
148
+ if key not in resource_attrs:
149
+ resource_attrs[key] = value
150
+ if detected:
151
+ logger.debug("Auto-detected resources: %s", list(detected.keys()))
152
+
153
+ resource = Resource.create(resource_attrs)
154
+
155
+ provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
156
+ trace.set_tracer_provider(provider)
157
+
158
+ lean_mode = cfg.propagation_mode == "lean"
159
+ provider.add_span_processor(RunContextEnricher(lean_mode=lean_mode))
160
+
161
+ exporter = OTLPSpanExporter(
162
+ endpoint=traces_endpoint,
163
+ headers=cfg.otlp_headers or {},
164
+ )
165
+ provider.add_span_processor(
166
+ BatchSpanProcessor(
167
+ exporter,
168
+ max_export_batch_size=cfg.max_export_batch_size,
169
+ max_queue_size=cfg.max_queue_size,
170
+ schedule_delay_millis=cfg.schedule_delay_millis,
171
+ export_timeout_millis=cfg.export_timeout_millis,
172
+ )
173
+ )
174
+
175
+ set_global_textmap(
176
+ CompositePropagator(
177
+ [
178
+ TraceContextTextMapPropagator(),
179
+ W3CBaggagePropagator(),
180
+ ]
181
+ )
182
+ )
183
+
184
+ logger.info("Botanu SDK tracing initialized")
185
+
186
+ # Set up LoggerProvider for outcome event emission
187
+ try:
188
+ from opentelemetry._logs import set_logger_provider
189
+ from opentelemetry.sdk._logs import LoggerProvider as _LoggerProvider
190
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
191
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
192
+
193
+ logs_endpoint = cfg.otlp_endpoint
194
+ if logs_endpoint and not logs_endpoint.endswith("/v1/logs"):
195
+ logs_endpoint = f"{logs_endpoint.rstrip('/')}/v1/logs"
196
+
197
+ log_provider = _LoggerProvider(resource=resource)
198
+ log_exporter = OTLPLogExporter(endpoint=logs_endpoint, headers=cfg.otlp_headers or {})
199
+ log_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
200
+ set_logger_provider(log_provider)
201
+ logger.info("Botanu SDK log provider initialized")
202
+ except ImportError:
203
+ logger.debug("OTel log exporter not available; outcome log emission disabled")
204
+
205
+ if auto_instrumentation:
206
+ _enable_auto_instrumentation()
207
+
208
+ _initialized = True
209
+ return True
210
+
211
+ except Exception as exc:
212
+ logger.error("Failed to initialize Botanu SDK: %s", exc, exc_info=True)
213
+ return False
214
+
215
+
216
+ def _enable_auto_instrumentation() -> None:
217
+ """Enable OTEL auto-instrumentation for common libraries.
218
+
219
+ Each instrumentation is optional — if the underlying library or
220
+ instrumentation package isn't installed, it is silently skipped.
221
+ """
222
+ enabled: List[str] = []
223
+ failed: List[tuple[str, str]] = []
224
+
225
+ # ── HTTP clients ──────────────────────────────────────────────
226
+ _try_instrument(enabled, failed, "httpx", "opentelemetry.instrumentation.httpx", "HTTPXClientInstrumentor")
227
+ _try_instrument(enabled, failed, "requests", "opentelemetry.instrumentation.requests", "RequestsInstrumentor")
228
+ _try_instrument(enabled, failed, "urllib3", "opentelemetry.instrumentation.urllib3", "URLLib3Instrumentor")
229
+ _try_instrument(enabled, failed, "urllib", "opentelemetry.instrumentation.urllib", "URLLibInstrumentor")
230
+ _try_instrument(
231
+ enabled, failed, "aiohttp_client", "opentelemetry.instrumentation.aiohttp_client", "AioHttpClientInstrumentor"
232
+ )
233
+ _try_instrument(
234
+ enabled, failed, "aiohttp_server", "opentelemetry.instrumentation.aiohttp_server", "AioHttpServerInstrumentor"
235
+ )
236
+
237
+ # ── Web frameworks ────────────────────────────────────────────
238
+ _try_instrument(enabled, failed, "fastapi", "opentelemetry.instrumentation.fastapi", "FastAPIInstrumentor")
239
+ _try_instrument(enabled, failed, "flask", "opentelemetry.instrumentation.flask", "FlaskInstrumentor")
240
+ _try_instrument(enabled, failed, "django", "opentelemetry.instrumentation.django", "DjangoInstrumentor")
241
+ _try_instrument(enabled, failed, "starlette", "opentelemetry.instrumentation.starlette", "StarletteInstrumentor")
242
+ _try_instrument(enabled, failed, "falcon", "opentelemetry.instrumentation.falcon", "FalconInstrumentor")
243
+ _try_instrument(enabled, failed, "pyramid", "opentelemetry.instrumentation.pyramid", "PyramidInstrumentor")
244
+ _try_instrument(enabled, failed, "tornado", "opentelemetry.instrumentation.tornado", "TornadoInstrumentor")
245
+
246
+ # ── Databases ─────────────────────────────────────────────────
247
+ _try_instrument(enabled, failed, "sqlalchemy", "opentelemetry.instrumentation.sqlalchemy", "SQLAlchemyInstrumentor")
248
+ _try_instrument(enabled, failed, "psycopg2", "opentelemetry.instrumentation.psycopg2", "Psycopg2Instrumentor")
249
+ _try_instrument(enabled, failed, "psycopg", "opentelemetry.instrumentation.psycopg", "PsycopgInstrumentor")
250
+ _try_instrument(enabled, failed, "asyncpg", "opentelemetry.instrumentation.asyncpg", "AsyncPGInstrumentor")
251
+ _try_instrument(enabled, failed, "aiopg", "opentelemetry.instrumentation.aiopg", "AiopgInstrumentor")
252
+ _try_instrument(enabled, failed, "pymongo", "opentelemetry.instrumentation.pymongo", "PymongoInstrumentor")
253
+ _try_instrument(enabled, failed, "redis", "opentelemetry.instrumentation.redis", "RedisInstrumentor")
254
+ _try_instrument(enabled, failed, "mysql", "opentelemetry.instrumentation.mysql", "MySQLInstrumentor")
255
+ _try_instrument(
256
+ enabled, failed, "mysqlclient", "opentelemetry.instrumentation.mysqlclient", "MySQLClientInstrumentor"
257
+ )
258
+ _try_instrument(enabled, failed, "pymysql", "opentelemetry.instrumentation.pymysql", "PyMySQLInstrumentor")
259
+ _try_instrument(enabled, failed, "sqlite3", "opentelemetry.instrumentation.sqlite3", "SQLite3Instrumentor")
260
+ _try_instrument(
261
+ enabled, failed, "elasticsearch", "opentelemetry.instrumentation.elasticsearch", "ElasticsearchInstrumentor"
262
+ )
263
+ _try_instrument(enabled, failed, "cassandra", "opentelemetry.instrumentation.cassandra", "CassandraInstrumentor")
264
+ _try_instrument(
265
+ enabled, failed, "tortoise_orm", "opentelemetry.instrumentation.tortoiseorm", "TortoiseORMInstrumentor"
266
+ )
267
+
268
+ # ── Caching ───────────────────────────────────────────────────
269
+ _try_instrument(enabled, failed, "pymemcache", "opentelemetry.instrumentation.pymemcache", "PymemcacheInstrumentor")
270
+
271
+ # ── Messaging / Task queues ───────────────────────────────────
272
+ _try_instrument(enabled, failed, "celery", "opentelemetry.instrumentation.celery", "CeleryInstrumentor")
273
+ _try_instrument(enabled, failed, "kafka-python", "opentelemetry.instrumentation.kafka_python", "KafkaInstrumentor")
274
+ _try_instrument(
275
+ enabled,
276
+ failed,
277
+ "confluent-kafka",
278
+ "opentelemetry.instrumentation.confluent_kafka",
279
+ "ConfluentKafkaInstrumentor",
280
+ )
281
+ _try_instrument(enabled, failed, "aiokafka", "opentelemetry.instrumentation.aiokafka", "AioKafkaInstrumentor")
282
+ _try_instrument(enabled, failed, "pika", "opentelemetry.instrumentation.pika", "PikaInstrumentor")
283
+ _try_instrument(enabled, failed, "aio-pika", "opentelemetry.instrumentation.aio_pika", "AioPikaInstrumentor")
284
+
285
+ # ── AWS ───────────────────────────────────────────────────────
286
+ _try_instrument(enabled, failed, "botocore", "opentelemetry.instrumentation.botocore", "BotocoreInstrumentor")
287
+ _try_instrument(enabled, failed, "boto3sqs", "opentelemetry.instrumentation.boto3sqs", "Boto3SQSInstrumentor")
288
+
289
+ # ── gRPC ──────────────────────────────────────────────────────
290
+ _try_instrument_grpc(enabled, failed)
291
+
292
+ # ── GenAI / AI ────────────────────────────────────────────────
293
+ _try_instrument(enabled, failed, "openai", "opentelemetry.instrumentation.openai_v2", "OpenAIInstrumentor")
294
+ _try_instrument(enabled, failed, "anthropic", "opentelemetry.instrumentation.anthropic", "AnthropicInstrumentor")
295
+ _try_instrument(enabled, failed, "vertexai", "opentelemetry.instrumentation.vertexai", "VertexAIInstrumentor")
296
+ _try_instrument(
297
+ enabled,
298
+ failed,
299
+ "google_genai",
300
+ "opentelemetry.instrumentation.google_generativeai",
301
+ "GoogleGenerativeAIInstrumentor",
302
+ )
303
+ _try_instrument(enabled, failed, "langchain", "opentelemetry.instrumentation.langchain", "LangchainInstrumentor")
304
+ _try_instrument(enabled, failed, "ollama", "opentelemetry.instrumentation.ollama", "OllamaInstrumentor")
305
+ _try_instrument(enabled, failed, "crewai", "opentelemetry.instrumentation.crewai", "CrewAIInstrumentor")
306
+
307
+ # ── Runtime / Concurrency ─────────────────────────────────────
308
+ _try_instrument(enabled, failed, "logging", "opentelemetry.instrumentation.logging", "LoggingInstrumentor")
309
+ _try_instrument(enabled, failed, "threading", "opentelemetry.instrumentation.threading", "ThreadingInstrumentor")
310
+ _try_instrument(enabled, failed, "asyncio", "opentelemetry.instrumentation.asyncio", "AsyncioInstrumentor")
311
+
312
+ if enabled:
313
+ logger.info("Auto-instrumentation enabled: %s", ", ".join(enabled))
314
+ if failed:
315
+ for name, error in failed:
316
+ logger.warning("Auto-instrumentation failed for %s: %s", name, error)
317
+
318
+
319
+ def _try_instrument(
320
+ enabled: List[str],
321
+ failed: List[tuple[str, str]],
322
+ name: str,
323
+ module_path: str,
324
+ class_name: str,
325
+ ) -> None:
326
+ """Try to import and instrument a single library."""
327
+ try:
328
+ import importlib
329
+
330
+ mod = importlib.import_module(module_path)
331
+ instrumentor_cls = getattr(mod, class_name)
332
+ instrumentor_cls().instrument()
333
+ enabled.append(name)
334
+ except ImportError:
335
+ pass
336
+ except Exception as exc:
337
+ failed.append((name, str(exc)))
338
+
339
+
340
+ def _try_instrument_grpc(
341
+ enabled: List[str],
342
+ failed: List[tuple[str, str]],
343
+ ) -> None:
344
+ """Try to instrument gRPC (client + server)."""
345
+ try:
346
+ from opentelemetry.instrumentation.grpc import (
347
+ GrpcInstrumentorClient,
348
+ GrpcInstrumentorServer,
349
+ )
350
+
351
+ GrpcInstrumentorClient().instrument()
352
+ GrpcInstrumentorServer().instrument()
353
+ enabled.append("grpc")
354
+ except ImportError:
355
+ pass
356
+ except Exception as exc:
357
+ failed.append(("grpc", str(exc)))
358
+
359
+
360
+ def is_enabled() -> bool:
361
+ """Check if Botanu SDK is initialized."""
362
+ return _initialized
363
+
364
+
365
+ def get_config() -> Optional[BotanuConfig]:
366
+ """Get the current Botanu configuration."""
367
+ return _current_config
368
+
369
+
370
+ def disable() -> None:
371
+ """Disable Botanu SDK and shutdown OTEL.
372
+
373
+ Call on application shutdown for clean exit.
374
+ """
375
+ global _initialized, _current_config
376
+
377
+ with _lock:
378
+ if not _initialized:
379
+ return
380
+
381
+ try:
382
+ from opentelemetry import trace
383
+
384
+ provider = trace.get_tracer_provider()
385
+ if hasattr(provider, "force_flush"):
386
+ provider.force_flush(timeout_millis=5000)
387
+ if hasattr(provider, "shutdown"):
388
+ provider.shutdown()
389
+
390
+ # Flush LoggerProvider (don't shutdown — it may be shared/external)
391
+ try:
392
+ from opentelemetry._logs import get_logger_provider
393
+
394
+ log_provider = get_logger_provider()
395
+ if hasattr(log_provider, "force_flush"):
396
+ log_provider.force_flush(timeout_millis=5000)
397
+ except Exception:
398
+ pass
399
+
400
+ _initialized = False
401
+ _current_config = None
402
+ logger.info("Botanu SDK shutdown complete")
403
+
404
+ except Exception as exc:
405
+ logger.error("Error during Botanu SDK shutdown: %s", exc)