spanforge 2.0.0__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.
Files changed (101) hide show
  1. spanforge/__init__.py +695 -0
  2. spanforge/_batch_exporter.py +322 -0
  3. spanforge/_cli.py +3081 -0
  4. spanforge/_hooks.py +340 -0
  5. spanforge/_server.py +953 -0
  6. spanforge/_span.py +1015 -0
  7. spanforge/_store.py +287 -0
  8. spanforge/_stream.py +654 -0
  9. spanforge/_trace.py +334 -0
  10. spanforge/_tracer.py +253 -0
  11. spanforge/actor.py +141 -0
  12. spanforge/alerts.py +464 -0
  13. spanforge/auto.py +181 -0
  14. spanforge/baseline.py +336 -0
  15. spanforge/config.py +460 -0
  16. spanforge/consent.py +227 -0
  17. spanforge/consumer.py +379 -0
  18. spanforge/core/__init__.py +5 -0
  19. spanforge/core/compliance_mapping.py +1060 -0
  20. spanforge/cost.py +597 -0
  21. spanforge/debug.py +514 -0
  22. spanforge/drift.py +488 -0
  23. spanforge/egress.py +63 -0
  24. spanforge/eval.py +575 -0
  25. spanforge/event.py +1052 -0
  26. spanforge/exceptions.py +246 -0
  27. spanforge/explain.py +181 -0
  28. spanforge/export/__init__.py +50 -0
  29. spanforge/export/append_only.py +342 -0
  30. spanforge/export/cloud.py +349 -0
  31. spanforge/export/datadog.py +495 -0
  32. spanforge/export/grafana.py +331 -0
  33. spanforge/export/jsonl.py +198 -0
  34. spanforge/export/otel_bridge.py +291 -0
  35. spanforge/export/otlp.py +817 -0
  36. spanforge/export/otlp_bridge.py +231 -0
  37. spanforge/export/redis_backend.py +282 -0
  38. spanforge/export/webhook.py +302 -0
  39. spanforge/exporters/__init__.py +29 -0
  40. spanforge/exporters/console.py +271 -0
  41. spanforge/exporters/jsonl.py +144 -0
  42. spanforge/hitl.py +297 -0
  43. spanforge/inspect.py +429 -0
  44. spanforge/integrations/__init__.py +39 -0
  45. spanforge/integrations/_pricing.py +277 -0
  46. spanforge/integrations/anthropic.py +388 -0
  47. spanforge/integrations/bedrock.py +306 -0
  48. spanforge/integrations/crewai.py +251 -0
  49. spanforge/integrations/gemini.py +349 -0
  50. spanforge/integrations/groq.py +444 -0
  51. spanforge/integrations/langchain.py +349 -0
  52. spanforge/integrations/llamaindex.py +370 -0
  53. spanforge/integrations/ollama.py +286 -0
  54. spanforge/integrations/openai.py +370 -0
  55. spanforge/integrations/together.py +485 -0
  56. spanforge/metrics.py +393 -0
  57. spanforge/metrics_export.py +342 -0
  58. spanforge/migrate.py +278 -0
  59. spanforge/model_registry.py +282 -0
  60. spanforge/models.py +407 -0
  61. spanforge/namespaces/__init__.py +215 -0
  62. spanforge/namespaces/audit.py +253 -0
  63. spanforge/namespaces/cache.py +209 -0
  64. spanforge/namespaces/chain.py +74 -0
  65. spanforge/namespaces/confidence.py +69 -0
  66. spanforge/namespaces/consent.py +85 -0
  67. spanforge/namespaces/cost.py +175 -0
  68. spanforge/namespaces/decision.py +135 -0
  69. spanforge/namespaces/diff.py +146 -0
  70. spanforge/namespaces/drift.py +79 -0
  71. spanforge/namespaces/eval_.py +232 -0
  72. spanforge/namespaces/fence.py +180 -0
  73. spanforge/namespaces/guard.py +104 -0
  74. spanforge/namespaces/hitl.py +92 -0
  75. spanforge/namespaces/latency.py +69 -0
  76. spanforge/namespaces/prompt.py +185 -0
  77. spanforge/namespaces/redact.py +172 -0
  78. spanforge/namespaces/template.py +197 -0
  79. spanforge/namespaces/tool_call.py +76 -0
  80. spanforge/namespaces/trace.py +1006 -0
  81. spanforge/normalizer.py +183 -0
  82. spanforge/presidio_backend.py +149 -0
  83. spanforge/processor.py +258 -0
  84. spanforge/prompt_registry.py +415 -0
  85. spanforge/py.typed +0 -0
  86. spanforge/redact.py +780 -0
  87. spanforge/sampling.py +500 -0
  88. spanforge/schemas/v1.0/schema.json +170 -0
  89. spanforge/schemas/v2.0/schema.json +536 -0
  90. spanforge/signing.py +1152 -0
  91. spanforge/stream.py +559 -0
  92. spanforge/testing.py +376 -0
  93. spanforge/trace.py +199 -0
  94. spanforge/types.py +696 -0
  95. spanforge/ulid.py +304 -0
  96. spanforge/validate.py +383 -0
  97. spanforge-2.0.0.dist-info/METADATA +1777 -0
  98. spanforge-2.0.0.dist-info/RECORD +101 -0
  99. spanforge-2.0.0.dist-info/WHEEL +4 -0
  100. spanforge-2.0.0.dist-info/entry_points.txt +5 -0
  101. spanforge-2.0.0.dist-info/licenses/LICENSE +21 -0
spanforge/testing.py ADDED
@@ -0,0 +1,376 @@
1
+ """spanforge.testing — Test utilities for SpanForge SDK consumers.
2
+
3
+ Provides helpers for writing unit and integration tests that involve
4
+ SpanForge events, exporters, and the trace store. Designed to be imported
5
+ only in test code (not in production).
6
+
7
+ Usage::
8
+
9
+ from spanforge.testing import capture_events, MockExporter, assert_event_schema_valid
10
+
11
+ # Capture all events emitted during a block
12
+ with capture_events() as captured:
13
+ # code that emits events
14
+ ...
15
+
16
+ assert len(captured) == 1
17
+ assert captured[0].event_type == "llm.trace.span.completed"
18
+ assert_event_schema_valid(captured[0])
19
+
20
+ # Inject a mock exporter
21
+ mock = MockExporter()
22
+ with mock.installed():
23
+ # code that emits events
24
+ ...
25
+ assert len(mock.events) == 1
26
+
27
+ # Isolated TraceStore for one test
28
+ from spanforge.testing import trace_store
29
+ with trace_store() as store:
30
+ configure(enable_trace_store=True)
31
+ # ... emit events ...
32
+ events = store.get_trace(trace_id)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import threading
39
+ from typing import TYPE_CHECKING, Any, Generator
40
+
41
+ if TYPE_CHECKING:
42
+ from spanforge.event import Event
43
+ from spanforge._store import TraceStore
44
+ from spanforge._span import Span
45
+
46
+ __all__ = [
47
+ "MockExporter",
48
+ "assert_event_schema_valid",
49
+ "assert_span_emitted",
50
+ "capture_events",
51
+ "captured_spans",
52
+ "trace_store",
53
+ ]
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # MockExporter
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class MockExporter:
62
+ """A synchronous in-memory exporter for testing.
63
+
64
+ Records every event passed to :meth:`export` into :attr:`events`.
65
+ Supports optional filtering, ordered access, and a context manager
66
+ that temporarily replaces the global exporter.
67
+
68
+ Args:
69
+ raise_on_export: When set to an :class:`Exception` subclass or
70
+ instance, :meth:`export` raises it to simulate
71
+ export failures.
72
+
73
+ Attributes:
74
+ events: List of all :class:`~spanforge.event.Event` objects exported
75
+ in chronological order.
76
+
77
+ Example::
78
+
79
+ mock = MockExporter()
80
+ with mock.installed():
81
+ tracer.span("test").__enter__().__exit__(None, None, None)
82
+
83
+ assert mock.events[0].event_type == "llm.trace.span.completed"
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ raise_on_export: type[Exception] | Exception | None = None,
89
+ ) -> None:
90
+ self.events: list[Event] = []
91
+ self._lock = threading.Lock()
92
+ self._raise_on_export = raise_on_export
93
+
94
+ def export(self, event: Event) -> None:
95
+ """Record *event*. Raises configured exception if one is set.
96
+
97
+ Args:
98
+ event: The event to record.
99
+
100
+ Raises:
101
+ Exception: The configured ``raise_on_export`` exception, if set.
102
+ """
103
+ if self._raise_on_export is not None:
104
+ if isinstance(self._raise_on_export, type):
105
+ raise self._raise_on_export("MockExporter.raise_on_export triggered")
106
+ raise self._raise_on_export
107
+ with self._lock:
108
+ self.events.append(event)
109
+
110
+ async def export_batch(self, events: Any) -> None: # NOSONAR
111
+ """Async batch export — records all events in *events*.
112
+
113
+ Args:
114
+ events: Iterable of :class:`~spanforge.event.Event` objects.
115
+ """
116
+ for event in events:
117
+ self.export(event)
118
+
119
+ def clear(self) -> None:
120
+ """Remove all recorded events."""
121
+ with self._lock:
122
+ self.events.clear()
123
+
124
+ def filter_by_type(self, event_type: str) -> list[Event]:
125
+ """Return all recorded events matching *event_type*.
126
+
127
+ Args:
128
+ event_type: Dotted event type string, e.g.
129
+ ``"llm.trace.span.completed"``.
130
+
131
+ Returns:
132
+ Filtered, ordered list of matching events.
133
+ """
134
+ et = str(event_type)
135
+ with self._lock:
136
+ return [
137
+ e for e in self.events
138
+ if (e.event_type.value if hasattr(e.event_type, "value") else str(e.event_type)) == et
139
+ ]
140
+
141
+ @contextlib.contextmanager
142
+ def installed(self) -> Generator[MockExporter, None, None]:
143
+ """Context manager that installs this exporter as the global exporter.
144
+
145
+ Replaces the SDK's active exporter for the duration of the block,
146
+ then restores the original state::
147
+
148
+ mock = MockExporter()
149
+ with mock.installed():
150
+ ... # all events go to mock.events
151
+
152
+ Yields:
153
+ This :class:`MockExporter` instance.
154
+ """
155
+ from spanforge._stream import _exporter_lock # noqa: PLC0415
156
+ import spanforge._stream as _stream # noqa: PLC0415
157
+
158
+ # Save state
159
+ with _exporter_lock:
160
+ original = _stream._cached_exporter
161
+ _stream._cached_exporter = self
162
+ try:
163
+ yield self
164
+ finally:
165
+ with _exporter_lock:
166
+ _stream._cached_exporter = original
167
+
168
+ def __repr__(self) -> str:
169
+ return f"MockExporter(events={len(self.events)})"
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # capture_events()
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ @contextlib.contextmanager
178
+ def capture_events() -> Generator[list[Event], None, None]:
179
+ """Context manager that captures all events emitted during the block.
180
+
181
+ Events are collected into a list that is yielded and grows in real-time
182
+ as events are emitted. The original exporter is restored on exit.
183
+
184
+ Example::
185
+
186
+ with capture_events() as events:
187
+ with tracer.span("test"):
188
+ pass
189
+
190
+ assert events[0].payload["span_name"] == "test"
191
+
192
+ Yields:
193
+ A live ``list[Event]`` that is populated as events are emitted.
194
+ """
195
+ mock = MockExporter()
196
+ with mock.installed():
197
+ yield mock.events
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # assert_event_schema_valid()
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ def assert_event_schema_valid(event: Event) -> None:
206
+ """Assert that *event* passes SDK schema validation.
207
+
208
+ Calls :func:`~spanforge.validate.validate_event` and re-raises any
209
+ :class:`~spanforge.exceptions.SchemaValidationError` as an
210
+ :class:`AssertionError` with the original message — so failures
211
+ surface cleanly in :func:`pytest.raises` and ``assert`` blocks.
212
+
213
+ Args:
214
+ event: The event to validate.
215
+
216
+ Raises:
217
+ AssertionError: If *event* fails schema validation.
218
+
219
+ Example::
220
+
221
+ from spanforge.testing import assert_event_schema_valid
222
+ assert_event_schema_valid(my_event)
223
+ """
224
+ from spanforge.exceptions import SchemaValidationError # noqa: PLC0415
225
+ from spanforge.validate import validate_event # noqa: PLC0415
226
+
227
+ try:
228
+ validate_event(event)
229
+ except SchemaValidationError as exc:
230
+ raise AssertionError(f"Event failed schema validation: {exc}") from exc
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # trace_store() context manager
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ @contextlib.contextmanager
239
+ def trace_store(max_traces: int = 100) -> Generator[TraceStore, None, None]:
240
+ """Context manager that provides an isolated :class:`~spanforge._store.TraceStore`.
241
+
242
+ Creates a fresh ``TraceStore`` scoped to the block and temporarily
243
+ installs it as the global store. The original store is restored on
244
+ exit, making this safe to use in parallel tests.
245
+
246
+ Args:
247
+ max_traces: Maximum number of traces to retain in the isolated store.
248
+
249
+ Yields:
250
+ A new :class:`~spanforge._store.TraceStore` instance scoped to the
251
+ ``with`` block.
252
+
253
+ Example::
254
+
255
+ from spanforge import configure
256
+ from spanforge.testing import trace_store
257
+
258
+ with trace_store() as store:
259
+ configure(enable_trace_store=True)
260
+ with tracer.span("test") as s:
261
+ pass
262
+ events = store.get_trace(s.trace_id)
263
+ assert events is not None
264
+ """
265
+ from spanforge._store import trace_store as _store_trace_store # noqa: PLC0415
266
+
267
+ with _store_trace_store(max_traces=max_traces) as store:
268
+ yield store
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # captured_spans() pytest fixture
273
+ # ---------------------------------------------------------------------------
274
+
275
+ try:
276
+ import pytest as _pytest
277
+
278
+ @_pytest.fixture
279
+ def captured_spans() -> Generator[list[Span], None, None]:
280
+ """pytest fixture that captures all :class:`~spanforge._span.Span` objects
281
+ completed during a test, regardless of operation type.
282
+
283
+ Import this fixture in your test module (or ``conftest.py``) to make it
284
+ available::
285
+
286
+ from spanforge.testing import captured_spans # re-export for pytest
287
+
288
+ def test_my_fn(captured_spans):
289
+ call_my_function()
290
+ assert any(s.name == "my-step" for s in captured_spans)
291
+
292
+ Each test gets an empty list; spans accumulate as the test runs.
293
+
294
+ Yields:
295
+ A live ``list[Span]`` populated as spans are completed.
296
+ """
297
+ from spanforge._hooks import hooks # noqa: PLC0415
298
+
299
+ spans: list[Any] = []
300
+
301
+ def _cb(span: Any) -> None:
302
+ spans.append(span)
303
+
304
+ hooks.on_span_end(_cb)
305
+ try:
306
+ yield spans # type: ignore[misc]
307
+ finally:
308
+ with hooks._lock:
309
+ try:
310
+ hooks._all_end_hooks.remove(_cb)
311
+ except ValueError:
312
+ pass
313
+
314
+ except ImportError:
315
+ # pytest not installed — skip fixture definition (production environments).
316
+ pass
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # assert_span_emitted()
321
+ # ---------------------------------------------------------------------------
322
+
323
+
324
+ def assert_span_emitted(
325
+ spans: list[Any],
326
+ *,
327
+ name: str,
328
+ model: str | None = None,
329
+ status: str | None = None,
330
+ operation: str | None = None,
331
+ ) -> Any:
332
+ """Assert that a span matching the given criteria appears in *spans*.
333
+
334
+ Typically used with the :func:`captured_spans` fixture.
335
+
336
+ Args:
337
+ spans: List of :class:`~spanforge._span.Span` objects (from fixture).
338
+ name: Required span name to match.
339
+ model: When provided, also checks ``span.model == model``.
340
+ status: When provided, also checks ``span.status == status``.
341
+ operation: When provided, also checks ``span.operation == operation``.
342
+
343
+ Returns:
344
+ The first matching :class:`~spanforge._span.Span`.
345
+
346
+ Raises:
347
+ AssertionError: If no span matches all criteria.
348
+
349
+ Example::
350
+
351
+ def test_llm_call(captured_spans):
352
+ run_agent()
353
+ assert_span_emitted(captured_spans, name="llm-call", model="gpt-4o")
354
+ """
355
+ for span in spans:
356
+ if span.name != name:
357
+ continue
358
+ if model is not None and span.model != model:
359
+ continue
360
+ if status is not None and span.status != status:
361
+ continue
362
+ if operation is not None and str(span.operation) != operation:
363
+ continue
364
+ return span
365
+
366
+ criteria = f"name={name!r}"
367
+ if model is not None:
368
+ criteria += f", model={model!r}"
369
+ if status is not None:
370
+ criteria += f", status={status!r}"
371
+ if operation is not None:
372
+ criteria += f", operation={operation!r}"
373
+ raise AssertionError(
374
+ f"No span matching {criteria} found in {len(spans)} captured span(s). "
375
+ f"Got: {[s.name for s in spans]}"
376
+ )
spanforge/trace.py ADDED
@@ -0,0 +1,199 @@
1
+ """spanforge.trace — @trace() function decorator and trace engine (Tool 1 / llm-trace).
2
+
3
+ Single decorator that instruments any Python function as an SpanForge span::
4
+
5
+ from spanforge import trace
6
+
7
+ @trace(name="search", model="gpt-4o")
8
+ def call_llm(prompt: str) -> str: ...
9
+
10
+ @trace(name="async-step")
11
+ async def async_step(x: int) -> dict: ...
12
+
13
+ @trace(name="web-search", tool=True)
14
+ def search_web(query: str) -> list[str]: ...
15
+
16
+ Supports:
17
+ - Sync and async functions/methods
18
+ - Auto-capture of call arguments and return values (opt-in)
19
+ - Parent-child span relationships via contextvars
20
+ - ``tool=True`` to emit spans with ``operation="execute_tool"``
21
+ - Pytest fixture integration via :func:`~spanforge.testing.captured_spans`
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import functools
27
+ import inspect
28
+ from typing import Any, Callable, TypeVar
29
+
30
+ from spanforge._span import SpanContextManager
31
+
32
+ __all__ = ["trace"]
33
+
34
+ _F = TypeVar("_F", bound=Callable[..., Any])
35
+
36
+
37
+ def _safe_repr(value: Any, max_len: int = 200) -> str:
38
+ """Return a repr of *value* truncated to *max_len* characters."""
39
+ try:
40
+ r = repr(value)
41
+ except Exception:
42
+ r = "<unrepresentable>"
43
+ return r[:max_len] + "..." if len(r) > max_len else r
44
+
45
+
46
+ class _TraceDecorator:
47
+ """Wraps a callable so every invocation is recorded as an SpanForge span.
48
+
49
+ Created by :func:`trace`; use :func:`trace` rather than instantiating
50
+ this class directly.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ fn: Callable[..., Any],
56
+ name: str | None,
57
+ model: str | None,
58
+ operation: str,
59
+ tool: bool,
60
+ capture_args: bool,
61
+ capture_return: bool,
62
+ attributes: dict[str, Any] | None,
63
+ ) -> None:
64
+ self._fn = fn
65
+ self._name = name or fn.__qualname__
66
+ self._model = model
67
+ self._operation = operation
68
+ self._tool = tool
69
+ self._capture_args = capture_args
70
+ self._capture_return = capture_return
71
+ self._attributes = dict(attributes or {})
72
+ # Preserve __name__, __doc__, __module__, etc. on the wrapper.
73
+ functools.update_wrapper(self, fn)
74
+
75
+ # ------------------------------------------------------------------
76
+ # Internal helpers
77
+ # ------------------------------------------------------------------
78
+
79
+ def _build_attrs(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
80
+ """Build the initial span attributes dict from static attrs + captured args."""
81
+ attrs: dict[str, Any] = dict(self._attributes)
82
+ if self._tool:
83
+ # Mark span so InspectorSession can detect it even without checking operation.
84
+ attrs["tool"] = True
85
+ if self._capture_args or self._tool:
86
+ try:
87
+ sig = inspect.signature(self._fn)
88
+ bound = sig.bind(*args, **kwargs)
89
+ bound.apply_defaults()
90
+ for k, v in bound.arguments.items():
91
+ attrs[f"arg.{k}"] = _safe_repr(v)
92
+ except (TypeError, ValueError):
93
+ pass
94
+ return attrs
95
+
96
+ def _record_return(self, span: Any, result: Any) -> None:
97
+ if self._capture_return or self._tool:
98
+ span.set_attribute("return_value", _safe_repr(result))
99
+
100
+ # ------------------------------------------------------------------
101
+ # Sync call
102
+ # ------------------------------------------------------------------
103
+
104
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
105
+ attrs = self._build_attrs(args, kwargs)
106
+ cm = SpanContextManager(
107
+ name=self._name,
108
+ model=self._model,
109
+ operation=self._operation,
110
+ attributes=attrs,
111
+ )
112
+ with cm as span:
113
+ result = self._fn(*args, **kwargs)
114
+ self._record_return(span, result)
115
+ return result
116
+
117
+
118
+ def _make_async_wrapper(decorator: _TraceDecorator, fn: Callable[..., Any]) -> Callable[..., Any]:
119
+ """Return an async wrapper that runs *fn* inside a span context."""
120
+
121
+ @functools.wraps(fn)
122
+ async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
123
+ attrs = decorator._build_attrs(args, kwargs)
124
+ cm = SpanContextManager(
125
+ name=decorator._name,
126
+ model=decorator._model,
127
+ operation=decorator._operation,
128
+ attributes=attrs,
129
+ )
130
+ async with cm as span:
131
+ result = await fn(*args, **kwargs)
132
+ decorator._record_return(span, result)
133
+ return result
134
+
135
+ return _async_wrapper
136
+
137
+
138
+ def trace(
139
+ fn: Callable[..., Any] | None = None,
140
+ *,
141
+ name: str | None = None,
142
+ model: str | None = None,
143
+ operation: str = "chat",
144
+ tool: bool = False,
145
+ capture_args: bool = False,
146
+ capture_return: bool = False,
147
+ attributes: dict[str, Any] | None = None,
148
+ ) -> Any:
149
+ """Decorator that instruments a function as an SpanForge span.
150
+
151
+ Works with or without parentheses::
152
+
153
+ @trace
154
+ def my_fn(): ...
155
+
156
+ @trace(name="custom-name", model="gpt-4o")
157
+ def my_fn(): ...
158
+
159
+ Args:
160
+ fn: The function to wrap (only when used bare as ``@trace``).
161
+ name: Span name. Defaults to ``fn.__qualname__``.
162
+ model: Model identifier string forwarded to ``SpanPayload``.
163
+ operation: GenAI operation name (default ``"chat"``). Any
164
+ :class:`~spanforge.namespaces.trace.GenAIOperationName`
165
+ value or a custom string.
166
+ tool: When ``True``, marks this as a tool call; sets
167
+ ``operation="execute_tool"`` regardless of *operation*.
168
+ capture_args: When ``True``, records call arguments as ``arg.<name>``
169
+ span attributes (values truncated to 200 chars).
170
+ capture_return: When ``True``, records the return value as a
171
+ ``return_value`` span attribute (truncated to 200 chars).
172
+ attributes: Static key-value attributes added to every span.
173
+
174
+ Returns:
175
+ Decorated callable (sync or async), or a single-argument decorator
176
+ when keyword arguments are supplied.
177
+ """
178
+
179
+ def _decorate(f: Callable[..., Any]) -> Callable[..., Any]:
180
+ effective_op = "execute_tool" if tool else operation
181
+ dec = _TraceDecorator(
182
+ fn=f,
183
+ name=name,
184
+ model=model,
185
+ operation=effective_op,
186
+ tool=tool,
187
+ capture_args=capture_args,
188
+ capture_return=capture_return,
189
+ attributes=attributes,
190
+ )
191
+ if inspect.iscoroutinefunction(f):
192
+ return _make_async_wrapper(dec, f)
193
+ return dec
194
+
195
+ if fn is not None:
196
+ # @trace — bare decorator, no parentheses
197
+ return _decorate(fn)
198
+ # @trace(...) — decorator factory
199
+ return _decorate