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.
- spanforge/__init__.py +695 -0
- spanforge/_batch_exporter.py +322 -0
- spanforge/_cli.py +3081 -0
- spanforge/_hooks.py +340 -0
- spanforge/_server.py +953 -0
- spanforge/_span.py +1015 -0
- spanforge/_store.py +287 -0
- spanforge/_stream.py +654 -0
- spanforge/_trace.py +334 -0
- spanforge/_tracer.py +253 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +464 -0
- spanforge/auto.py +181 -0
- spanforge/baseline.py +336 -0
- spanforge/config.py +460 -0
- spanforge/consent.py +227 -0
- spanforge/consumer.py +379 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1060 -0
- spanforge/cost.py +597 -0
- spanforge/debug.py +514 -0
- spanforge/drift.py +488 -0
- spanforge/egress.py +63 -0
- spanforge/eval.py +575 -0
- spanforge/event.py +1052 -0
- spanforge/exceptions.py +246 -0
- spanforge/explain.py +181 -0
- spanforge/export/__init__.py +50 -0
- spanforge/export/append_only.py +342 -0
- spanforge/export/cloud.py +349 -0
- spanforge/export/datadog.py +495 -0
- spanforge/export/grafana.py +331 -0
- spanforge/export/jsonl.py +198 -0
- spanforge/export/otel_bridge.py +291 -0
- spanforge/export/otlp.py +817 -0
- spanforge/export/otlp_bridge.py +231 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/webhook.py +302 -0
- spanforge/exporters/__init__.py +29 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/hitl.py +297 -0
- spanforge/inspect.py +429 -0
- spanforge/integrations/__init__.py +39 -0
- spanforge/integrations/_pricing.py +277 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/bedrock.py +306 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +349 -0
- spanforge/integrations/groq.py +444 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/llamaindex.py +370 -0
- spanforge/integrations/ollama.py +286 -0
- spanforge/integrations/openai.py +370 -0
- spanforge/integrations/together.py +485 -0
- spanforge/metrics.py +393 -0
- spanforge/metrics_export.py +342 -0
- spanforge/migrate.py +278 -0
- spanforge/model_registry.py +282 -0
- spanforge/models.py +407 -0
- spanforge/namespaces/__init__.py +215 -0
- spanforge/namespaces/audit.py +253 -0
- spanforge/namespaces/cache.py +209 -0
- spanforge/namespaces/chain.py +74 -0
- spanforge/namespaces/confidence.py +69 -0
- spanforge/namespaces/consent.py +85 -0
- spanforge/namespaces/cost.py +175 -0
- spanforge/namespaces/decision.py +135 -0
- spanforge/namespaces/diff.py +146 -0
- spanforge/namespaces/drift.py +79 -0
- spanforge/namespaces/eval_.py +232 -0
- spanforge/namespaces/fence.py +180 -0
- spanforge/namespaces/guard.py +104 -0
- spanforge/namespaces/hitl.py +92 -0
- spanforge/namespaces/latency.py +69 -0
- spanforge/namespaces/prompt.py +185 -0
- spanforge/namespaces/redact.py +172 -0
- spanforge/namespaces/template.py +197 -0
- spanforge/namespaces/tool_call.py +76 -0
- spanforge/namespaces/trace.py +1006 -0
- spanforge/normalizer.py +183 -0
- spanforge/presidio_backend.py +149 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +415 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +780 -0
- spanforge/sampling.py +500 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/signing.py +1152 -0
- spanforge/stream.py +559 -0
- spanforge/testing.py +376 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +304 -0
- spanforge/validate.py +383 -0
- spanforge-2.0.0.dist-info/METADATA +1777 -0
- spanforge-2.0.0.dist-info/RECORD +101 -0
- spanforge-2.0.0.dist-info/WHEEL +4 -0
- spanforge-2.0.0.dist-info/entry_points.txt +5 -0
- 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
|