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.
@@ -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)
@@ -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
+ ]