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/decorators.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The Botanu Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Decorators for automatic run span creation and context propagation.
|
|
5
|
+
|
|
6
|
+
The ``@botanu_workflow`` decorator is the primary integration point.
|
|
7
|
+
It creates a "run span" that:
|
|
8
|
+
- Generates a UUIDv7 run_id
|
|
9
|
+
- Emits ``run.started`` and ``run.completed`` events
|
|
10
|
+
- Propagates run context via W3C Baggage
|
|
11
|
+
- Records outcome at completion
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import functools
|
|
18
|
+
import hashlib
|
|
19
|
+
import inspect
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import Any, Callable, Dict, Generator, Optional, TypeVar, Union
|
|
24
|
+
|
|
25
|
+
from opentelemetry import baggage as otel_baggage
|
|
26
|
+
from opentelemetry import trace
|
|
27
|
+
from opentelemetry.context import attach, detach, get_current
|
|
28
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
29
|
+
|
|
30
|
+
from botanu.models.run_context import RunContext, RunStatus
|
|
31
|
+
from botanu.sdk.context import get_baggage
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
tracer = trace.get_tracer("botanu_sdk")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _compute_workflow_version(func: Callable[..., Any]) -> str:
|
|
39
|
+
try:
|
|
40
|
+
source = inspect.getsource(func)
|
|
41
|
+
code_hash = hashlib.sha256(source.encode()).hexdigest()
|
|
42
|
+
return f"v:{code_hash[:12]}"
|
|
43
|
+
except (OSError, TypeError):
|
|
44
|
+
return "v:unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_parent_run_id() -> Optional[str]:
|
|
48
|
+
return get_baggage("botanu.run_id")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def botanu_workflow(
|
|
52
|
+
name: str,
|
|
53
|
+
*,
|
|
54
|
+
event_id: Union[str, Callable[..., str]],
|
|
55
|
+
customer_id: Union[str, Callable[..., str]],
|
|
56
|
+
environment: Optional[str] = None,
|
|
57
|
+
tenant_id: Optional[str] = None,
|
|
58
|
+
auto_outcome_on_success: bool = True,
|
|
59
|
+
span_kind: SpanKind = SpanKind.SERVER,
|
|
60
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
61
|
+
"""Decorator to create a run span with automatic context propagation.
|
|
62
|
+
|
|
63
|
+
This is the primary integration point. It:
|
|
64
|
+
|
|
65
|
+
1. Creates a UUIDv7 ``run_id`` (sortable, globally unique)
|
|
66
|
+
2. Creates a ``botanu.run`` span as the root of the run
|
|
67
|
+
3. Emits ``run.started`` event
|
|
68
|
+
4. Propagates run context via W3C Baggage
|
|
69
|
+
5. On completion: emits ``run.completed`` event with outcome
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
name: Workflow name (low cardinality, e.g. ``"Customer Support"``).
|
|
73
|
+
event_id: Business unit of work (e.g. ticket ID). Required.
|
|
74
|
+
Can be a static string or a callable that receives the same
|
|
75
|
+
``(*args, **kwargs)`` as the decorated function and returns a string.
|
|
76
|
+
customer_id: End-customer being served (e.g. org ID). Required.
|
|
77
|
+
Can be a static string or a callable (same signature as *event_id*).
|
|
78
|
+
environment: Deployment environment.
|
|
79
|
+
tenant_id: Tenant identifier for multi-tenant apps.
|
|
80
|
+
auto_outcome_on_success: Emit ``"success"`` if no exception.
|
|
81
|
+
span_kind: OpenTelemetry span kind (default: ``SERVER``).
|
|
82
|
+
|
|
83
|
+
Examples::
|
|
84
|
+
|
|
85
|
+
# Static values (known at decoration time):
|
|
86
|
+
@botanu_workflow("Support", event_id="ticket-123", customer_id="acme-corp")
|
|
87
|
+
async def handle_ticket(): ...
|
|
88
|
+
|
|
89
|
+
# Dynamic values (extracted from function arguments at call time):
|
|
90
|
+
@botanu_workflow(
|
|
91
|
+
"Support",
|
|
92
|
+
event_id=lambda request: request.workflow_id,
|
|
93
|
+
customer_id=lambda request: request.customer_id,
|
|
94
|
+
)
|
|
95
|
+
async def handle_ticket(request: TicketRequest): ...
|
|
96
|
+
"""
|
|
97
|
+
if isinstance(event_id, str) and not event_id:
|
|
98
|
+
raise ValueError("event_id is required and must be a non-empty string")
|
|
99
|
+
if isinstance(customer_id, str) and not customer_id:
|
|
100
|
+
raise ValueError("customer_id is required and must be a non-empty string")
|
|
101
|
+
if not callable(event_id) and not isinstance(event_id, str):
|
|
102
|
+
raise ValueError("event_id must be a non-empty string or a callable")
|
|
103
|
+
if not callable(customer_id) and not isinstance(customer_id, str):
|
|
104
|
+
raise ValueError("customer_id must be a non-empty string or a callable")
|
|
105
|
+
|
|
106
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
107
|
+
workflow_version = _compute_workflow_version(func)
|
|
108
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
109
|
+
|
|
110
|
+
@functools.wraps(func)
|
|
111
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> T:
|
|
112
|
+
resolved_event_id = event_id(*args, **kwargs) if callable(event_id) else event_id
|
|
113
|
+
resolved_customer_id = customer_id(*args, **kwargs) if callable(customer_id) else customer_id
|
|
114
|
+
parent_run_id = _get_parent_run_id()
|
|
115
|
+
run_ctx = RunContext.create(
|
|
116
|
+
workflow=name,
|
|
117
|
+
event_id=resolved_event_id,
|
|
118
|
+
customer_id=resolved_customer_id,
|
|
119
|
+
workflow_version=workflow_version,
|
|
120
|
+
environment=environment,
|
|
121
|
+
tenant_id=tenant_id,
|
|
122
|
+
parent_run_id=parent_run_id,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
with tracer.start_as_current_span(
|
|
126
|
+
name=f"botanu.run/{name}",
|
|
127
|
+
kind=span_kind,
|
|
128
|
+
) as span:
|
|
129
|
+
for key, value in run_ctx.to_span_attributes().items():
|
|
130
|
+
span.set_attribute(key, value)
|
|
131
|
+
|
|
132
|
+
span.add_event(
|
|
133
|
+
"botanu.run.started",
|
|
134
|
+
attributes={
|
|
135
|
+
"run_id": run_ctx.run_id,
|
|
136
|
+
"workflow": run_ctx.workflow,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
ctx = get_current()
|
|
141
|
+
for key, value in run_ctx.to_baggage_dict().items():
|
|
142
|
+
ctx = otel_baggage.set_baggage(key, value, context=ctx)
|
|
143
|
+
baggage_token = attach(ctx)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
result = await func(*args, **kwargs)
|
|
147
|
+
|
|
148
|
+
span_attrs = getattr(span, "attributes", None)
|
|
149
|
+
existing_outcome = (
|
|
150
|
+
span_attrs.get("botanu.outcome.status") if isinstance(span_attrs, Mapping) else None
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if existing_outcome is None and auto_outcome_on_success:
|
|
154
|
+
run_ctx.complete(RunStatus.SUCCESS)
|
|
155
|
+
|
|
156
|
+
span.set_status(Status(StatusCode.OK))
|
|
157
|
+
_emit_run_completed(span, run_ctx, RunStatus.SUCCESS)
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
span.set_status(Status(StatusCode.ERROR, str(exc)))
|
|
162
|
+
span.record_exception(exc)
|
|
163
|
+
run_ctx.complete(RunStatus.FAILURE, error_class=exc.__class__.__name__)
|
|
164
|
+
_emit_run_completed(
|
|
165
|
+
span,
|
|
166
|
+
run_ctx,
|
|
167
|
+
RunStatus.FAILURE,
|
|
168
|
+
error_class=exc.__class__.__name__,
|
|
169
|
+
)
|
|
170
|
+
raise
|
|
171
|
+
finally:
|
|
172
|
+
detach(baggage_token)
|
|
173
|
+
|
|
174
|
+
@functools.wraps(func)
|
|
175
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> T:
|
|
176
|
+
resolved_event_id = event_id(*args, **kwargs) if callable(event_id) else event_id
|
|
177
|
+
resolved_customer_id = customer_id(*args, **kwargs) if callable(customer_id) else customer_id
|
|
178
|
+
parent_run_id = _get_parent_run_id()
|
|
179
|
+
run_ctx = RunContext.create(
|
|
180
|
+
workflow=name,
|
|
181
|
+
event_id=resolved_event_id,
|
|
182
|
+
customer_id=resolved_customer_id,
|
|
183
|
+
workflow_version=workflow_version,
|
|
184
|
+
environment=environment,
|
|
185
|
+
tenant_id=tenant_id,
|
|
186
|
+
parent_run_id=parent_run_id,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
with tracer.start_as_current_span(
|
|
190
|
+
name=f"botanu.run/{name}",
|
|
191
|
+
kind=span_kind,
|
|
192
|
+
) as span:
|
|
193
|
+
for key, value in run_ctx.to_span_attributes().items():
|
|
194
|
+
span.set_attribute(key, value)
|
|
195
|
+
|
|
196
|
+
span.add_event(
|
|
197
|
+
"botanu.run.started",
|
|
198
|
+
attributes={
|
|
199
|
+
"run_id": run_ctx.run_id,
|
|
200
|
+
"workflow": run_ctx.workflow,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
ctx = get_current()
|
|
205
|
+
for key, value in run_ctx.to_baggage_dict().items():
|
|
206
|
+
ctx = otel_baggage.set_baggage(key, value, context=ctx)
|
|
207
|
+
baggage_token = attach(ctx)
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
result = func(*args, **kwargs)
|
|
211
|
+
|
|
212
|
+
span_attrs = getattr(span, "attributes", None)
|
|
213
|
+
existing_outcome = (
|
|
214
|
+
span_attrs.get("botanu.outcome.status") if isinstance(span_attrs, Mapping) else None
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if existing_outcome is None and auto_outcome_on_success:
|
|
218
|
+
run_ctx.complete(RunStatus.SUCCESS)
|
|
219
|
+
|
|
220
|
+
span.set_status(Status(StatusCode.OK))
|
|
221
|
+
_emit_run_completed(span, run_ctx, RunStatus.SUCCESS)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
span.set_status(Status(StatusCode.ERROR, str(exc)))
|
|
226
|
+
span.record_exception(exc)
|
|
227
|
+
run_ctx.complete(RunStatus.FAILURE, error_class=exc.__class__.__name__)
|
|
228
|
+
_emit_run_completed(
|
|
229
|
+
span,
|
|
230
|
+
run_ctx,
|
|
231
|
+
RunStatus.FAILURE,
|
|
232
|
+
error_class=exc.__class__.__name__,
|
|
233
|
+
)
|
|
234
|
+
raise
|
|
235
|
+
finally:
|
|
236
|
+
detach(baggage_token)
|
|
237
|
+
|
|
238
|
+
if is_async:
|
|
239
|
+
return async_wrapper # type: ignore[return-value]
|
|
240
|
+
return sync_wrapper # type: ignore[return-value]
|
|
241
|
+
|
|
242
|
+
return decorator
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _emit_run_completed(
|
|
246
|
+
span: trace.Span,
|
|
247
|
+
run_ctx: RunContext,
|
|
248
|
+
status: RunStatus,
|
|
249
|
+
error_class: Optional[str] = None,
|
|
250
|
+
) -> None:
|
|
251
|
+
duration_ms = (datetime.now(timezone.utc) - run_ctx.start_time).total_seconds() * 1000
|
|
252
|
+
|
|
253
|
+
event_attrs: Dict[str, Union[str, float]] = {
|
|
254
|
+
"run_id": run_ctx.run_id,
|
|
255
|
+
"workflow": run_ctx.workflow,
|
|
256
|
+
"status": status.value,
|
|
257
|
+
"duration_ms": duration_ms,
|
|
258
|
+
}
|
|
259
|
+
if error_class:
|
|
260
|
+
event_attrs["error_class"] = error_class
|
|
261
|
+
if run_ctx.outcome and run_ctx.outcome.value_type:
|
|
262
|
+
event_attrs["value_type"] = run_ctx.outcome.value_type
|
|
263
|
+
if run_ctx.outcome and run_ctx.outcome.value_amount is not None:
|
|
264
|
+
event_attrs["value_amount"] = run_ctx.outcome.value_amount
|
|
265
|
+
|
|
266
|
+
span.add_event("botanu.run.completed", attributes=event_attrs)
|
|
267
|
+
|
|
268
|
+
span.set_attribute("botanu.outcome.status", status.value)
|
|
269
|
+
span.set_attribute("botanu.run.duration_ms", duration_ms)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
workflow = botanu_workflow
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def botanu_outcome(
|
|
276
|
+
success: Optional[str] = None,
|
|
277
|
+
partial: Optional[str] = None,
|
|
278
|
+
failed: Optional[str] = None,
|
|
279
|
+
) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
|
280
|
+
"""Decorator to automatically emit outcomes based on function result.
|
|
281
|
+
|
|
282
|
+
This is a convenience decorator for sub-functions within a workflow.
|
|
283
|
+
It does NOT create a new run — use ``@botanu_workflow`` for that.
|
|
284
|
+
"""
|
|
285
|
+
from botanu.sdk.span_helpers import emit_outcome
|
|
286
|
+
|
|
287
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
288
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
289
|
+
|
|
290
|
+
@functools.wraps(func)
|
|
291
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> T:
|
|
292
|
+
try:
|
|
293
|
+
result = await func(*args, **kwargs)
|
|
294
|
+
span = trace.get_current_span()
|
|
295
|
+
if not span.attributes or "botanu.outcome.status" not in span.attributes:
|
|
296
|
+
emit_outcome("success")
|
|
297
|
+
return result
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
emit_outcome("failed", reason=exc.__class__.__name__)
|
|
300
|
+
raise
|
|
301
|
+
|
|
302
|
+
@functools.wraps(func)
|
|
303
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> T:
|
|
304
|
+
try:
|
|
305
|
+
result = func(*args, **kwargs)
|
|
306
|
+
span = trace.get_current_span()
|
|
307
|
+
if not span.attributes or "botanu.outcome.status" not in span.attributes:
|
|
308
|
+
emit_outcome("success")
|
|
309
|
+
return result
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
emit_outcome("failed", reason=exc.__class__.__name__)
|
|
312
|
+
raise
|
|
313
|
+
|
|
314
|
+
if is_async:
|
|
315
|
+
return async_wrapper # type: ignore[return-value]
|
|
316
|
+
return sync_wrapper # type: ignore[return-value]
|
|
317
|
+
|
|
318
|
+
return decorator
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@contextmanager
|
|
322
|
+
def run_botanu(
|
|
323
|
+
name: str,
|
|
324
|
+
*,
|
|
325
|
+
event_id: str,
|
|
326
|
+
customer_id: str,
|
|
327
|
+
environment: Optional[str] = None,
|
|
328
|
+
tenant_id: Optional[str] = None,
|
|
329
|
+
auto_outcome_on_success: bool = True,
|
|
330
|
+
span_kind: SpanKind = SpanKind.SERVER,
|
|
331
|
+
) -> Generator[RunContext, None, None]:
|
|
332
|
+
"""Context manager to create a run span — non-decorator alternative to ``@botanu_workflow``.
|
|
333
|
+
|
|
334
|
+
Use this when you can't decorate a function (dynamic workflows, simple scripts,
|
|
335
|
+
or when the workflow name is determined at runtime).
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
name: Workflow name (low cardinality, e.g. ``"Customer Support"``).
|
|
339
|
+
event_id: Business unit of work (e.g. ticket ID).
|
|
340
|
+
customer_id: End-customer being served (e.g. org ID).
|
|
341
|
+
environment: Deployment environment.
|
|
342
|
+
tenant_id: Tenant identifier for multi-tenant apps.
|
|
343
|
+
auto_outcome_on_success: Emit ``"success"`` if no exception.
|
|
344
|
+
span_kind: OpenTelemetry span kind (default: ``SERVER``).
|
|
345
|
+
|
|
346
|
+
Yields:
|
|
347
|
+
RunContext with the generated run_id and metadata.
|
|
348
|
+
|
|
349
|
+
Example::
|
|
350
|
+
|
|
351
|
+
with run_botanu("Support", event_id="ticket-42", customer_id="acme") as run:
|
|
352
|
+
result = call_llm(...)
|
|
353
|
+
emit_outcome("success", value_type="tickets_resolved", value_amount=1)
|
|
354
|
+
"""
|
|
355
|
+
parent_run_id = _get_parent_run_id()
|
|
356
|
+
run_ctx = RunContext.create(
|
|
357
|
+
workflow=name,
|
|
358
|
+
event_id=event_id,
|
|
359
|
+
customer_id=customer_id,
|
|
360
|
+
environment=environment,
|
|
361
|
+
tenant_id=tenant_id,
|
|
362
|
+
parent_run_id=parent_run_id,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
with tracer.start_as_current_span(
|
|
366
|
+
name=f"botanu.run/{name}",
|
|
367
|
+
kind=span_kind,
|
|
368
|
+
) as span:
|
|
369
|
+
for key, value in run_ctx.to_span_attributes().items():
|
|
370
|
+
span.set_attribute(key, value)
|
|
371
|
+
|
|
372
|
+
span.add_event(
|
|
373
|
+
"botanu.run.started",
|
|
374
|
+
attributes={"run_id": run_ctx.run_id, "workflow": run_ctx.workflow},
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
ctx = get_current()
|
|
378
|
+
for key, value in run_ctx.to_baggage_dict().items():
|
|
379
|
+
ctx = otel_baggage.set_baggage(key, value, context=ctx)
|
|
380
|
+
baggage_token = attach(ctx)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
yield run_ctx
|
|
384
|
+
|
|
385
|
+
span_attrs = getattr(span, "attributes", None)
|
|
386
|
+
existing_outcome = (
|
|
387
|
+
span_attrs.get("botanu.outcome.status")
|
|
388
|
+
if isinstance(span_attrs, Mapping)
|
|
389
|
+
else None
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if existing_outcome is None and auto_outcome_on_success:
|
|
393
|
+
run_ctx.complete(RunStatus.SUCCESS)
|
|
394
|
+
|
|
395
|
+
span.set_status(Status(StatusCode.OK))
|
|
396
|
+
_emit_run_completed(span, run_ctx, RunStatus.SUCCESS)
|
|
397
|
+
|
|
398
|
+
except Exception as exc:
|
|
399
|
+
span.set_status(Status(StatusCode.ERROR, str(exc)))
|
|
400
|
+
span.record_exception(exc)
|
|
401
|
+
run_ctx.complete(RunStatus.FAILURE, error_class=exc.__class__.__name__)
|
|
402
|
+
_emit_run_completed(
|
|
403
|
+
span, run_ctx, RunStatus.FAILURE, error_class=exc.__class__.__name__,
|
|
404
|
+
)
|
|
405
|
+
raise
|
|
406
|
+
finally:
|
|
407
|
+
detach(baggage_token)
|
botanu/sdk/middleware.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The Botanu Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""FastAPI / Starlette middleware for span enrichment.
|
|
5
|
+
|
|
6
|
+
This middleware works alongside OpenTelemetry's FastAPIInstrumentor to enrich
|
|
7
|
+
spans with Botanu-specific context.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from opentelemetry import baggage as otel_baggage
|
|
16
|
+
from opentelemetry import trace
|
|
17
|
+
from opentelemetry.context import attach, detach, get_current
|
|
18
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
19
|
+
from starlette.requests import Request
|
|
20
|
+
from starlette.responses import Response
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BotanuMiddleware(BaseHTTPMiddleware):
|
|
24
|
+
"""FastAPI middleware to enrich spans with Botanu context.
|
|
25
|
+
|
|
26
|
+
This middleware should be used **after** OpenTelemetry's
|
|
27
|
+
``FastAPIInstrumentor``. It extracts Botanu context from incoming
|
|
28
|
+
requests and enriches the current span with Botanu attributes.
|
|
29
|
+
|
|
30
|
+
Example::
|
|
31
|
+
|
|
32
|
+
from fastapi import FastAPI
|
|
33
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|
34
|
+
from botanu.sdk.middleware import BotanuMiddleware
|
|
35
|
+
|
|
36
|
+
app = FastAPI()
|
|
37
|
+
FastAPIInstrumentor.instrument_app(app)
|
|
38
|
+
app.add_middleware(
|
|
39
|
+
BotanuMiddleware,
|
|
40
|
+
workflow="customer_support",
|
|
41
|
+
)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
app: object,
|
|
47
|
+
*,
|
|
48
|
+
workflow: str,
|
|
49
|
+
auto_generate_run_id: bool = True,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(app) # type: ignore[arg-type]
|
|
52
|
+
self.workflow = workflow
|
|
53
|
+
self.auto_generate_run_id = auto_generate_run_id
|
|
54
|
+
|
|
55
|
+
async def dispatch(self, request: Request, call_next: object) -> Response: # type: ignore[override]
|
|
56
|
+
"""Process request and enrich span with Botanu context."""
|
|
57
|
+
span = trace.get_current_span()
|
|
58
|
+
|
|
59
|
+
run_id = otel_baggage.get_baggage("botanu.run_id")
|
|
60
|
+
if not run_id:
|
|
61
|
+
run_id = request.headers.get("x-botanu-run-id")
|
|
62
|
+
|
|
63
|
+
if not run_id and self.auto_generate_run_id:
|
|
64
|
+
run_id = str(uuid.uuid4())
|
|
65
|
+
|
|
66
|
+
workflow = (
|
|
67
|
+
otel_baggage.get_baggage("botanu.workflow") or request.headers.get("x-botanu-workflow") or self.workflow
|
|
68
|
+
)
|
|
69
|
+
customer_id = otel_baggage.get_baggage("botanu.customer_id") or request.headers.get("x-botanu-customer-id")
|
|
70
|
+
|
|
71
|
+
if run_id:
|
|
72
|
+
span.set_attribute("botanu.run_id", run_id)
|
|
73
|
+
span.set_attribute("botanu.workflow", workflow)
|
|
74
|
+
if customer_id:
|
|
75
|
+
span.set_attribute("botanu.customer_id", customer_id)
|
|
76
|
+
|
|
77
|
+
span.set_attribute("http.route", request.url.path)
|
|
78
|
+
span.set_attribute("http.method", request.method)
|
|
79
|
+
|
|
80
|
+
ctx = get_current()
|
|
81
|
+
if run_id:
|
|
82
|
+
ctx = otel_baggage.set_baggage("botanu.run_id", run_id, context=ctx)
|
|
83
|
+
ctx = otel_baggage.set_baggage("botanu.workflow", workflow, context=ctx)
|
|
84
|
+
if customer_id:
|
|
85
|
+
ctx = otel_baggage.set_baggage("botanu.customer_id", customer_id, context=ctx)
|
|
86
|
+
|
|
87
|
+
baggage_token = attach(ctx)
|
|
88
|
+
try:
|
|
89
|
+
response = await call_next(request) # type: ignore[misc]
|
|
90
|
+
finally:
|
|
91
|
+
detach(baggage_token)
|
|
92
|
+
|
|
93
|
+
if run_id:
|
|
94
|
+
response.headers["x-botanu-run-id"] = run_id
|
|
95
|
+
response.headers["x-botanu-workflow"] = workflow
|
|
96
|
+
|
|
97
|
+
return response
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The Botanu Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Helper functions for working with OpenTelemetry spans.
|
|
5
|
+
|
|
6
|
+
These functions add Botanu-specific attributes to the current span.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from opentelemetry import trace
|
|
15
|
+
|
|
16
|
+
from botanu.sdk.context import get_baggage
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
VALID_OUTCOME_STATUSES = {
|
|
21
|
+
"success", "partial", "failed", "timeout", "canceled", "abandoned",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def emit_outcome(
|
|
26
|
+
status: str,
|
|
27
|
+
*,
|
|
28
|
+
value_type: Optional[str] = None,
|
|
29
|
+
value_amount: Optional[float] = None,
|
|
30
|
+
confidence: Optional[float] = None,
|
|
31
|
+
reason: Optional[str] = None,
|
|
32
|
+
error_type: Optional[str] = None,
|
|
33
|
+
metadata: Optional[dict[str, str]] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Emit an outcome for the current span.
|
|
36
|
+
|
|
37
|
+
Sets span attributes for outcome tracking and ROI calculation.
|
|
38
|
+
Also emits an OTel log record to trigger collector flush.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
status: Outcome status. Must be one of ``"success"``, ``"partial"``,
|
|
42
|
+
``"failed"``, ``"timeout"``, ``"canceled"``, ``"abandoned"``.
|
|
43
|
+
value_type: Type of business value (e.g., ``"tickets_resolved"``).
|
|
44
|
+
value_amount: Quantified value amount.
|
|
45
|
+
confidence: Confidence score (0.0–1.0).
|
|
46
|
+
reason: Optional reason for the outcome.
|
|
47
|
+
error_type: Error classification (e.g., ``"ValidationError"``).
|
|
48
|
+
metadata: Additional key-value metadata to attach to the outcome.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If *status* is not a recognised outcome status.
|
|
52
|
+
|
|
53
|
+
Example::
|
|
54
|
+
|
|
55
|
+
>>> emit_outcome("success", value_type="tickets_resolved", value_amount=1)
|
|
56
|
+
>>> emit_outcome("failed", error_type="TimeoutError", reason="LLM took >30s")
|
|
57
|
+
"""
|
|
58
|
+
if status not in VALID_OUTCOME_STATUSES:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Invalid outcome status '{status}'. "
|
|
61
|
+
f"Must be one of: {', '.join(sorted(VALID_OUTCOME_STATUSES))}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
span = trace.get_current_span()
|
|
65
|
+
|
|
66
|
+
span.set_attribute("botanu.outcome.status", status)
|
|
67
|
+
|
|
68
|
+
if value_type:
|
|
69
|
+
span.set_attribute("botanu.outcome.value_type", value_type)
|
|
70
|
+
|
|
71
|
+
if value_amount is not None:
|
|
72
|
+
span.set_attribute("botanu.outcome.value_amount", value_amount)
|
|
73
|
+
|
|
74
|
+
if confidence is not None:
|
|
75
|
+
span.set_attribute("botanu.outcome.confidence", confidence)
|
|
76
|
+
|
|
77
|
+
if reason:
|
|
78
|
+
span.set_attribute("botanu.outcome.reason", reason)
|
|
79
|
+
|
|
80
|
+
if error_type:
|
|
81
|
+
span.set_attribute("botanu.outcome.error_type", error_type)
|
|
82
|
+
|
|
83
|
+
if metadata:
|
|
84
|
+
for key, value in metadata.items():
|
|
85
|
+
span.set_attribute(f"botanu.outcome.metadata.{key}", value)
|
|
86
|
+
|
|
87
|
+
event_attrs: dict[str, object] = {"status": status}
|
|
88
|
+
if value_type:
|
|
89
|
+
event_attrs["value_type"] = value_type
|
|
90
|
+
if value_amount is not None:
|
|
91
|
+
event_attrs["value_amount"] = value_amount
|
|
92
|
+
if error_type:
|
|
93
|
+
event_attrs["error_type"] = error_type
|
|
94
|
+
|
|
95
|
+
span.add_event("botanu.outcome_emitted", event_attrs)
|
|
96
|
+
|
|
97
|
+
# Emit OTel log record for collector flush trigger
|
|
98
|
+
event_id = get_baggage("botanu.event_id")
|
|
99
|
+
if event_id:
|
|
100
|
+
try:
|
|
101
|
+
from opentelemetry._logs import get_logger_provider
|
|
102
|
+
|
|
103
|
+
logger_provider = get_logger_provider()
|
|
104
|
+
otel_logger = logger_provider.get_logger("botanu.outcome")
|
|
105
|
+
otel_logger.emit(
|
|
106
|
+
body=f"outcome:{status}",
|
|
107
|
+
attributes={
|
|
108
|
+
"botanu.event_id": event_id,
|
|
109
|
+
"botanu.outcome.status": status,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
except Exception:
|
|
113
|
+
pass # Don't break user's code if logs not configured
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def set_business_context(
|
|
117
|
+
*,
|
|
118
|
+
customer_id: Optional[str] = None,
|
|
119
|
+
team: Optional[str] = None,
|
|
120
|
+
cost_center: Optional[str] = None,
|
|
121
|
+
region: Optional[str] = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Set business context attributes on the current span.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
customer_id: Customer identifier for multi-tenant attribution.
|
|
127
|
+
team: Team or department.
|
|
128
|
+
cost_center: Cost centre for financial tracking.
|
|
129
|
+
region: Geographic region.
|
|
130
|
+
"""
|
|
131
|
+
span = trace.get_current_span()
|
|
132
|
+
|
|
133
|
+
if customer_id:
|
|
134
|
+
span.set_attribute("botanu.customer_id", customer_id)
|
|
135
|
+
|
|
136
|
+
if team:
|
|
137
|
+
span.set_attribute("botanu.team", team)
|
|
138
|
+
|
|
139
|
+
if cost_center:
|
|
140
|
+
span.set_attribute("botanu.cost_center", cost_center)
|
|
141
|
+
|
|
142
|
+
if region:
|
|
143
|
+
span.set_attribute("botanu.region", region)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The Botanu Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Botanu tracking components.
|
|
5
|
+
|
|
6
|
+
Provides tracking for different operation types:
|
|
7
|
+
- LLM/GenAI model calls
|
|
8
|
+
- Database, storage, and messaging operations
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from botanu.tracking.data import (
|
|
14
|
+
DBOperation,
|
|
15
|
+
MessagingOperation,
|
|
16
|
+
StorageOperation,
|
|
17
|
+
set_data_metrics,
|
|
18
|
+
set_warehouse_metrics,
|
|
19
|
+
track_db_operation,
|
|
20
|
+
track_messaging_operation,
|
|
21
|
+
track_storage_operation,
|
|
22
|
+
)
|
|
23
|
+
from botanu.tracking.llm import (
|
|
24
|
+
BotanuAttributes,
|
|
25
|
+
GenAIAttributes,
|
|
26
|
+
LLMTracker,
|
|
27
|
+
ModelOperation,
|
|
28
|
+
ToolTracker,
|
|
29
|
+
set_llm_attributes,
|
|
30
|
+
set_token_usage,
|
|
31
|
+
track_llm_call,
|
|
32
|
+
track_tool_call,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# LLM tracking
|
|
37
|
+
"track_llm_call",
|
|
38
|
+
"track_tool_call",
|
|
39
|
+
"set_llm_attributes",
|
|
40
|
+
"set_token_usage",
|
|
41
|
+
"ModelOperation",
|
|
42
|
+
"GenAIAttributes",
|
|
43
|
+
"BotanuAttributes",
|
|
44
|
+
"LLMTracker",
|
|
45
|
+
"ToolTracker",
|
|
46
|
+
# Data tracking
|
|
47
|
+
"track_db_operation",
|
|
48
|
+
"track_storage_operation",
|
|
49
|
+
"track_messaging_operation",
|
|
50
|
+
"set_data_metrics",
|
|
51
|
+
"set_warehouse_metrics",
|
|
52
|
+
"DBOperation",
|
|
53
|
+
"StorageOperation",
|
|
54
|
+
"MessagingOperation",
|
|
55
|
+
]
|