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/__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,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"]
|