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 ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Botanu SDK - OpenTelemetry-native cost attribution for AI workflows.
5
+
6
+ Quick Start::
7
+
8
+ from botanu import enable, botanu_workflow, emit_outcome
9
+
10
+ enable() # reads config from OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT env vars
11
+
12
+ @botanu_workflow(name="Customer Support")
13
+ async def handle_request(data):
14
+ result = await process(data)
15
+ emit_outcome("success", value_type="tickets_resolved", value_amount=1)
16
+ return result
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from botanu._version import __version__
22
+
23
+ # Run context model
24
+ from botanu.models.run_context import RunContext, RunOutcome, RunStatus
25
+
26
+ # Bootstrap
27
+ from botanu.sdk.bootstrap import (
28
+ disable,
29
+ enable,
30
+ is_enabled,
31
+ )
32
+
33
+ # Configuration
34
+ from botanu.sdk.config import BotanuConfig
35
+
36
+ # Context helpers (core — no SDK dependency)
37
+ from botanu.sdk.context import (
38
+ get_baggage,
39
+ get_current_span,
40
+ get_run_id,
41
+ get_workflow,
42
+ set_baggage,
43
+ )
44
+
45
+ # Decorators (primary integration point)
46
+ from botanu.sdk.decorators import botanu_workflow, run_botanu, workflow
47
+
48
+ # Span helpers
49
+ from botanu.sdk.span_helpers import emit_outcome, set_business_context
50
+
51
+ __all__ = [
52
+ "__version__",
53
+ # Bootstrap
54
+ "enable",
55
+ "disable",
56
+ "is_enabled",
57
+ # Configuration
58
+ "BotanuConfig",
59
+ # Decorators / context managers
60
+ "botanu_workflow",
61
+ "run_botanu",
62
+ "workflow",
63
+ # Span helpers
64
+ "emit_outcome",
65
+ "set_business_context",
66
+ "get_current_span",
67
+ # Context
68
+ "get_run_id",
69
+ "get_workflow",
70
+ "set_baggage",
71
+ "get_baggage",
72
+ # Run context
73
+ "RunContext",
74
+ "RunStatus",
75
+ "RunOutcome",
76
+ ]
botanu/_version.py ADDED
@@ -0,0 +1,13 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Dynamic version from package metadata (set by hatch-vcs at build time)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ try:
9
+ from importlib.metadata import version
10
+
11
+ __version__: str = version("botanu")
12
+ except Exception:
13
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Botanu integrations with third-party libraries."""
@@ -0,0 +1,60 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Tenacity retry integration — automatic attempt tracking for LLM calls.
5
+
6
+ Stamps ``botanu.request.attempt`` on every span created inside a tenacity
7
+ retry loop so the collector and cost engine can see how many attempts an
8
+ event required.
9
+
10
+ Usage::
11
+
12
+ from tenacity import retry, stop_after_attempt, wait_exponential
13
+ from botanu.integrations.tenacity import botanu_before, botanu_after_all
14
+ from botanu.tracking.llm import track_llm_call
15
+
16
+ @retry(
17
+ stop=stop_after_attempt(3),
18
+ wait=wait_exponential(min=1, max=10),
19
+ before=botanu_before,
20
+ after=botanu_after_all, # optional — resets attempt counter
21
+ )
22
+ def call_llm():
23
+ with track_llm_call("openai", "gpt-4") as tracker:
24
+ response = openai.chat.completions.create(...)
25
+ tracker.set_tokens(
26
+ input_tokens=response.usage.prompt_tokens,
27
+ output_tokens=response.usage.completion_tokens,
28
+ )
29
+ return response
30
+
31
+ The ``track_llm_call`` context manager reads the attempt number
32
+ automatically — no need to call ``tracker.set_attempt()`` manually.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Any
38
+
39
+ from botanu.tracking.llm import _retry_attempt
40
+
41
+
42
+ def botanu_before(retry_state: Any) -> None:
43
+ """Tenacity ``before`` callback — sets the current attempt number.
44
+
45
+ Use as ``@retry(before=botanu_before)`` so that every
46
+ ``track_llm_call`` inside the retried function automatically
47
+ gets the correct attempt number on its span.
48
+ """
49
+ _retry_attempt.set(retry_state.attempt_number)
50
+
51
+
52
+ def botanu_after_all(retry_state: Any) -> None:
53
+ """Tenacity ``after`` callback — resets the attempt counter.
54
+
55
+ Optional but recommended. Prevents a stale attempt number from
56
+ leaking into subsequent non-retried calls on the same thread.
57
+
58
+ Use as ``@retry(after=botanu_after_all)``.
59
+ """
60
+ _retry_attempt.set(0)
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Botanu data models."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from botanu.models.run_context import RunContext, RunOutcome, RunStatus
9
+
10
+ __all__ = ["RunContext", "RunOutcome", "RunStatus"]
@@ -0,0 +1,328 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Run Context - The core data model for Botanu runs.
5
+
6
+ A "Run" is orthogonal to tracing:
7
+ - Trace context (W3C): ties distributed spans together (trace_id, span_id)
8
+ - Run context (Botanu): ties business execution together (run_id, workflow, outcome)
9
+
10
+ Invariant: A run can span multiple traces (retries, async fanout).
11
+ The run_id must remain stable across those boundaries.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from enum import Enum
21
+ from typing import Dict, Optional, Union
22
+
23
+
24
+ def generate_run_id() -> str:
25
+ """Generate a UUIDv7-style sortable run ID.
26
+
27
+ UUIDv7 provides:
28
+ - Sortable by time (first 48 bits are millisecond timestamp)
29
+ - Globally unique
30
+ - Compatible with UUID format
31
+
32
+ Uses ``os.urandom()`` for ~2x faster generation than ``secrets``.
33
+ """
34
+ timestamp_ms = int(time.time() * 1000)
35
+
36
+ uuid_bytes = bytearray(16)
37
+ uuid_bytes[0] = (timestamp_ms >> 40) & 0xFF
38
+ uuid_bytes[1] = (timestamp_ms >> 32) & 0xFF
39
+ uuid_bytes[2] = (timestamp_ms >> 24) & 0xFF
40
+ uuid_bytes[3] = (timestamp_ms >> 16) & 0xFF
41
+ uuid_bytes[4] = (timestamp_ms >> 8) & 0xFF
42
+ uuid_bytes[5] = timestamp_ms & 0xFF
43
+
44
+ random_bytes = os.urandom(10)
45
+ uuid_bytes[6] = 0x70 | (random_bytes[0] & 0x0F)
46
+ uuid_bytes[7] = random_bytes[1]
47
+ uuid_bytes[8] = 0x80 | (random_bytes[2] & 0x3F)
48
+ uuid_bytes[9:16] = random_bytes[3:10]
49
+
50
+ hex_str = uuid_bytes.hex()
51
+ return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:]}"
52
+
53
+
54
+ class RunStatus(str, Enum):
55
+ """Run outcome status."""
56
+
57
+ SUCCESS = "success"
58
+ FAILURE = "failure"
59
+ PARTIAL = "partial"
60
+ TIMEOUT = "timeout"
61
+ CANCELED = "canceled"
62
+
63
+
64
+ @dataclass
65
+ class RunOutcome:
66
+ """Outcome attached at run completion."""
67
+
68
+ status: RunStatus
69
+ reason_code: Optional[str] = None
70
+ error_class: Optional[str] = None
71
+ value_type: Optional[str] = None
72
+ value_amount: Optional[float] = None
73
+ confidence: Optional[float] = None
74
+
75
+
76
+ @dataclass
77
+ class RunContext:
78
+ """Canonical run context data model.
79
+
80
+ Propagated via W3C Baggage and stored as span attributes.
81
+
82
+ Retry model:
83
+ Each attempt gets a NEW run_id for clean cost accounting.
84
+ ``root_run_id`` stays stable across all attempts.
85
+ """
86
+
87
+ run_id: str
88
+ workflow: str
89
+ event_id: str
90
+ customer_id: str
91
+ environment: str
92
+ workflow_version: Optional[str] = None
93
+ tenant_id: Optional[str] = None
94
+ parent_run_id: Optional[str] = None
95
+ root_run_id: Optional[str] = None
96
+ attempt: int = 1
97
+ retry_of_run_id: Optional[str] = None
98
+ start_time: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
99
+ deadline: Optional[float] = None
100
+ cancelled: bool = False
101
+ cancelled_at: Optional[float] = None
102
+ outcome: Optional[RunOutcome] = None
103
+
104
+ def __post_init__(self) -> None:
105
+ if self.root_run_id is None:
106
+ object.__setattr__(self, "root_run_id", self.run_id)
107
+
108
+ # ------------------------------------------------------------------
109
+ # Factory
110
+ # ------------------------------------------------------------------
111
+
112
+ @classmethod
113
+ def create(
114
+ cls,
115
+ workflow: str,
116
+ event_id: str,
117
+ customer_id: str,
118
+ workflow_version: Optional[str] = None,
119
+ environment: Optional[str] = None,
120
+ tenant_id: Optional[str] = None,
121
+ parent_run_id: Optional[str] = None,
122
+ root_run_id: Optional[str] = None,
123
+ attempt: int = 1,
124
+ retry_of_run_id: Optional[str] = None,
125
+ deadline_seconds: Optional[float] = None,
126
+ ) -> RunContext:
127
+ """Create a new RunContext with auto-generated run_id."""
128
+ env = environment or os.getenv("BOTANU_ENVIRONMENT") or os.getenv("DEPLOYMENT_ENVIRONMENT") or "production"
129
+ run_id = generate_run_id()
130
+ deadline = None
131
+ if deadline_seconds is not None:
132
+ deadline = time.time() + deadline_seconds
133
+
134
+ return cls(
135
+ run_id=run_id,
136
+ workflow=workflow,
137
+ event_id=event_id,
138
+ customer_id=customer_id,
139
+ environment=env,
140
+ workflow_version=workflow_version,
141
+ tenant_id=tenant_id,
142
+ parent_run_id=parent_run_id,
143
+ root_run_id=root_run_id or run_id,
144
+ attempt=attempt,
145
+ retry_of_run_id=retry_of_run_id,
146
+ deadline=deadline,
147
+ )
148
+
149
+ @classmethod
150
+ def create_retry(cls, previous: RunContext) -> RunContext:
151
+ """Create a new RunContext for a retry attempt."""
152
+ return cls.create(
153
+ workflow=previous.workflow,
154
+ event_id=previous.event_id,
155
+ customer_id=previous.customer_id,
156
+ workflow_version=previous.workflow_version,
157
+ environment=previous.environment,
158
+ tenant_id=previous.tenant_id,
159
+ parent_run_id=previous.parent_run_id,
160
+ root_run_id=previous.root_run_id,
161
+ attempt=previous.attempt + 1,
162
+ retry_of_run_id=previous.run_id,
163
+ )
164
+
165
+ # ------------------------------------------------------------------
166
+ # Lifecycle
167
+ # ------------------------------------------------------------------
168
+
169
+ def is_past_deadline(self) -> bool:
170
+ if self.deadline is None:
171
+ return False
172
+ return time.time() > self.deadline
173
+
174
+ def is_cancelled(self) -> bool:
175
+ return self.cancelled or self.is_past_deadline()
176
+
177
+ def request_cancellation(self, reason: str = "user") -> None:
178
+ self.cancelled = True
179
+ self.cancelled_at = time.time()
180
+
181
+ def remaining_time_seconds(self) -> Optional[float]:
182
+ if self.deadline is None:
183
+ return None
184
+ return max(0.0, self.deadline - time.time())
185
+
186
+ def complete(
187
+ self,
188
+ status: RunStatus,
189
+ reason_code: Optional[str] = None,
190
+ error_class: Optional[str] = None,
191
+ value_type: Optional[str] = None,
192
+ value_amount: Optional[float] = None,
193
+ confidence: Optional[float] = None,
194
+ ) -> None:
195
+ self.outcome = RunOutcome(
196
+ status=status,
197
+ reason_code=reason_code,
198
+ error_class=error_class,
199
+ value_type=value_type,
200
+ value_amount=value_amount,
201
+ confidence=confidence,
202
+ )
203
+
204
+ @property
205
+ def duration_ms(self) -> Optional[float]:
206
+ if self.outcome is None:
207
+ return None
208
+ return (datetime.now(timezone.utc) - self.start_time).total_seconds() * 1000
209
+
210
+ # ------------------------------------------------------------------
211
+ # Serialisation
212
+ # ------------------------------------------------------------------
213
+
214
+ def to_baggage_dict(self, lean_mode: Optional[bool] = None) -> Dict[str, str]:
215
+ """Convert to dict for W3C Baggage propagation."""
216
+ if lean_mode is None:
217
+ env_mode = os.getenv("BOTANU_PROPAGATION_MODE", "lean")
218
+ lean_mode = env_mode != "full"
219
+
220
+ baggage: Dict[str, str] = {
221
+ "botanu.run_id": self.run_id,
222
+ "botanu.workflow": self.workflow,
223
+ "botanu.event_id": self.event_id,
224
+ "botanu.customer_id": self.customer_id,
225
+ }
226
+ if lean_mode:
227
+ return baggage
228
+
229
+ baggage["botanu.environment"] = self.environment
230
+ if self.tenant_id:
231
+ baggage["botanu.tenant_id"] = self.tenant_id
232
+ if self.parent_run_id:
233
+ baggage["botanu.parent_run_id"] = self.parent_run_id
234
+ if self.root_run_id and self.root_run_id != self.run_id:
235
+ baggage["botanu.root_run_id"] = self.root_run_id
236
+ if self.attempt > 1:
237
+ baggage["botanu.attempt"] = str(self.attempt)
238
+ if self.retry_of_run_id:
239
+ baggage["botanu.retry_of_run_id"] = self.retry_of_run_id
240
+ if self.deadline is not None:
241
+ baggage["botanu.deadline"] = str(int(self.deadline * 1000))
242
+ if self.cancelled:
243
+ baggage["botanu.cancelled"] = "true"
244
+ return baggage
245
+
246
+ def to_span_attributes(self) -> Dict[str, Union[str, float, int, bool]]:
247
+ """Convert to dict for span attributes."""
248
+ attrs: Dict[str, Union[str, float, int, bool]] = {
249
+ "botanu.run_id": self.run_id,
250
+ "botanu.workflow": self.workflow,
251
+ "botanu.event_id": self.event_id,
252
+ "botanu.customer_id": self.customer_id,
253
+ "botanu.environment": self.environment,
254
+ "botanu.run.start_time": self.start_time.isoformat(),
255
+ }
256
+ if self.workflow_version:
257
+ attrs["botanu.workflow.version"] = self.workflow_version
258
+ if self.tenant_id:
259
+ attrs["botanu.tenant_id"] = self.tenant_id
260
+ if self.parent_run_id:
261
+ attrs["botanu.parent_run_id"] = self.parent_run_id
262
+ attrs["botanu.root_run_id"] = self.root_run_id or self.run_id
263
+ attrs["botanu.attempt"] = self.attempt
264
+ if self.retry_of_run_id:
265
+ attrs["botanu.retry_of_run_id"] = self.retry_of_run_id
266
+ if self.deadline is not None:
267
+ attrs["botanu.run.deadline_ts"] = self.deadline
268
+ if self.cancelled:
269
+ attrs["botanu.run.cancelled"] = True
270
+ if self.cancelled_at:
271
+ attrs["botanu.run.cancelled_at"] = self.cancelled_at
272
+ if self.outcome:
273
+ attrs["botanu.outcome.status"] = self.outcome.status.value
274
+ if self.outcome.reason_code:
275
+ attrs["botanu.outcome.reason_code"] = self.outcome.reason_code
276
+ if self.outcome.error_class:
277
+ attrs["botanu.outcome.error_class"] = self.outcome.error_class
278
+ if self.outcome.value_type:
279
+ attrs["botanu.outcome.value_type"] = self.outcome.value_type
280
+ if self.outcome.value_amount is not None:
281
+ attrs["botanu.outcome.value_amount"] = self.outcome.value_amount
282
+ if self.outcome.confidence is not None:
283
+ attrs["botanu.outcome.confidence"] = self.outcome.confidence
284
+ if self.duration_ms is not None:
285
+ attrs["botanu.run.duration_ms"] = self.duration_ms
286
+ return attrs
287
+
288
+ @classmethod
289
+ def from_baggage(cls, baggage: Dict[str, str]) -> Optional[RunContext]:
290
+ """Reconstruct RunContext from baggage dict."""
291
+ run_id = baggage.get("botanu.run_id")
292
+ workflow = baggage.get("botanu.workflow")
293
+ if not run_id or not workflow:
294
+ return None
295
+
296
+ attempt_str = baggage.get("botanu.attempt", "1")
297
+ try:
298
+ attempt = int(attempt_str)
299
+ except ValueError:
300
+ attempt = 1
301
+
302
+ deadline: Optional[float] = None
303
+ deadline_str = baggage.get("botanu.deadline")
304
+ if deadline_str:
305
+ try:
306
+ deadline = float(deadline_str) / 1000.0
307
+ except ValueError:
308
+ pass
309
+
310
+ cancelled = baggage.get("botanu.cancelled", "").lower() == "true"
311
+
312
+ event_id = baggage.get("botanu.event_id", "")
313
+ customer_id = baggage.get("botanu.customer_id", "")
314
+
315
+ return cls(
316
+ run_id=run_id,
317
+ workflow=workflow,
318
+ event_id=event_id,
319
+ customer_id=customer_id,
320
+ environment=baggage.get("botanu.environment", "unknown"),
321
+ tenant_id=baggage.get("botanu.tenant_id"),
322
+ parent_run_id=baggage.get("botanu.parent_run_id"),
323
+ root_run_id=baggage.get("botanu.root_run_id") or run_id,
324
+ attempt=attempt,
325
+ retry_of_run_id=baggage.get("botanu.retry_of_run_id"),
326
+ deadline=deadline,
327
+ cancelled=cancelled,
328
+ )
@@ -0,0 +1,12 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Botanu span processors.
5
+
6
+ Only :class:`RunContextEnricher` is needed in the SDK.
7
+ All other processing should happen in the OTel Collector.
8
+ """
9
+
10
+ from botanu.processors.enricher import RunContextEnricher
11
+
12
+ __all__ = ["RunContextEnricher"]
@@ -0,0 +1,84 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """RunContextEnricher — the only span processor needed in the SDK.
5
+
6
+ Why this MUST be in SDK (not collector):
7
+ - Baggage is process-local (not sent over the wire).
8
+ - Only the SDK can read baggage and write it to span attributes.
9
+ - The collector only sees spans after they're exported.
10
+
11
+ All heavy processing should happen in the OTel Collector:
12
+ - PII redaction → ``redactionprocessor``
13
+ - Cardinality limits → ``attributesprocessor``
14
+ - Vendor detection → ``transformprocessor``
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import ClassVar, List, Optional
21
+
22
+ from opentelemetry import baggage, context
23
+ from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
24
+ from opentelemetry.trace import Span
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class RunContextEnricher(SpanProcessor):
30
+ """Enriches ALL spans with run context from baggage.
31
+
32
+ This ensures that every span (including auto-instrumented ones)
33
+ gets ``botanu.run_id``, ``botanu.workflow``, etc. attributes.
34
+
35
+ Without this processor, only the root ``botanu.run`` span would
36
+ have these attributes.
37
+
38
+ In ``lean_mode`` (default), only ``run_id`` and ``workflow`` are
39
+ propagated to minimise per-span overhead.
40
+ """
41
+
42
+ BAGGAGE_KEYS_FULL: ClassVar[List[str]] = [
43
+ "botanu.run_id",
44
+ "botanu.workflow",
45
+ "botanu.event_id",
46
+ "botanu.customer_id",
47
+ "botanu.environment",
48
+ "botanu.tenant_id",
49
+ "botanu.parent_run_id",
50
+ ]
51
+
52
+ BAGGAGE_KEYS_LEAN: ClassVar[List[str]] = [
53
+ "botanu.run_id",
54
+ "botanu.workflow",
55
+ "botanu.event_id",
56
+ "botanu.customer_id",
57
+ ]
58
+
59
+ def __init__(self, lean_mode: bool = True) -> None:
60
+ self._lean_mode = lean_mode
61
+ self._baggage_keys = self.BAGGAGE_KEYS_LEAN if lean_mode else self.BAGGAGE_KEYS_FULL
62
+
63
+ def on_start(
64
+ self,
65
+ span: Span,
66
+ parent_context: Optional[context.Context] = None,
67
+ ) -> None:
68
+ """Called when a span starts — enrich with run context from baggage."""
69
+ ctx = parent_context or context.get_current()
70
+
71
+ for key in self._baggage_keys:
72
+ value = baggage.get_baggage(key, ctx)
73
+ if value:
74
+ if not span.attributes or key not in span.attributes:
75
+ span.set_attribute(key, value)
76
+
77
+ def on_end(self, span: ReadableSpan) -> None:
78
+ pass
79
+
80
+ def shutdown(self) -> None:
81
+ pass
82
+
83
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
84
+ return True
botanu/py.typed ADDED
File without changes
@@ -0,0 +1,87 @@
1
+ # SPDX-FileCopyrightText: 2026 The Botanu Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Resource detection using official OTel community detectors.
5
+
6
+ Instead of a custom reimplementation, we try to import the official
7
+ OpenTelemetry resource detector packages. Each one is a lightweight
8
+ pip package that auto-detects environment attributes (K8s, AWS, GCP,
9
+ Azure, container). If a package isn't installed, we gracefully skip it.
10
+
11
+ Install detectors for your environment::
12
+
13
+ pip install botanu[aws] # AWS EC2/ECS/EKS/Lambda
14
+ pip install botanu[gcp] # GCE/GKE/Cloud Run/Cloud Functions
15
+ pip install botanu[azure] # Azure VMs/App Service/Functions
16
+ pip install botanu[cloud] # All cloud detectors
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import importlib
22
+ import logging
23
+ from typing import Any, Dict, List, Tuple
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # (module_path, class_name) — tried in order.
28
+ # Each entry corresponds to a pip package from opentelemetry-python-contrib.
29
+ _DETECTOR_REGISTRY: List[Tuple[str, str]] = [
30
+ # Built-in (opentelemetry-sdk — always available)
31
+ ("opentelemetry.sdk.resources", "ProcessResourceDetector"),
32
+ # opentelemetry-resource-detector-aws
33
+ ("opentelemetry.resource.detector.aws.ec2", "AwsEc2ResourceDetector"),
34
+ ("opentelemetry.resource.detector.aws.ecs", "AwsEcsResourceDetector"),
35
+ ("opentelemetry.resource.detector.aws.eks", "AwsEksResourceDetector"),
36
+ ("opentelemetry.resource.detector.aws.lambda_", "AwsLambdaResourceDetector"),
37
+ # opentelemetry-resource-detector-gcp
38
+ ("opentelemetry.resource.detector.gcp", "GoogleCloudResourceDetector"),
39
+ # opentelemetry-resource-detector-azure
40
+ ("opentelemetry.resource.detector.azure.vm", "AzureVMResourceDetector"),
41
+ ("opentelemetry.resource.detector.azure.app_service", "AzureAppServiceResourceDetector"),
42
+ # opentelemetry-resource-detector-container
43
+ ("opentelemetry.resource.detector.container", "ContainerResourceDetector"),
44
+ ]
45
+
46
+
47
+ def collect_detectors() -> list:
48
+ """Return instances of all importable OTel resource detectors.
49
+
50
+ Each detector implements ``opentelemetry.sdk.resources.ResourceDetector``.
51
+ Missing packages are silently skipped.
52
+ """
53
+ detectors: list = []
54
+ for module_path, class_name in _DETECTOR_REGISTRY:
55
+ try:
56
+ mod = importlib.import_module(module_path)
57
+ cls = getattr(mod, class_name)
58
+ detectors.append(cls())
59
+ except (ImportError, AttributeError):
60
+ pass
61
+
62
+ if detectors:
63
+ names = [type(d).__name__ for d in detectors]
64
+ logger.debug("Available resource detectors: %s", names)
65
+
66
+ return detectors
67
+
68
+
69
+ def detect_resource_attrs() -> Dict[str, Any]:
70
+ """Detect environment attributes using available OTel detectors.
71
+
72
+ Returns a flat dict of resource attributes. This is a convenience
73
+ wrapper for callers that just need a dict (like bootstrap.py).
74
+ """
75
+ attrs: Dict[str, Any] = {}
76
+ for detector in collect_detectors():
77
+ try:
78
+ resource = detector.detect()
79
+ attrs.update(dict(resource.attributes))
80
+ except Exception:
81
+ # Community detectors may raise on network timeouts, missing
82
+ # metadata endpoints, etc. Never let detection break SDK init.
83
+ logger.debug("Resource detector %s failed", type(detector).__name__, exc_info=True)
84
+ return attrs
85
+
86
+
87
+ __all__ = ["collect_detectors", "detect_resource_attrs"]