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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Webhook exporter for spanforge events.
|
|
2
|
+
|
|
3
|
+
Delivers events (or batches) as JSON HTTP POST requests to a configurable
|
|
4
|
+
URL with optional HMAC-SHA256 request signing.
|
|
5
|
+
|
|
6
|
+
Security
|
|
7
|
+
--------
|
|
8
|
+
* If ``secret`` is provided every request is signed with
|
|
9
|
+
``X-SpanForge-Signature: hmac-sha256:<hex>`` so the receiver can verify
|
|
10
|
+
authenticity.
|
|
11
|
+
* The ``secret`` value is **never** included in repr, logs, or exception
|
|
12
|
+
messages.
|
|
13
|
+
* Retry logic uses truncated exponential back-off to avoid amplifying load on a
|
|
14
|
+
degraded endpoint.
|
|
15
|
+
|
|
16
|
+
Transport
|
|
17
|
+
---------
|
|
18
|
+
Uses :func:`urllib.request.urlopen` in a thread-pool executor so the async
|
|
19
|
+
event loop is never blocked. No external HTTP library is required.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import contextvars
|
|
26
|
+
import hashlib
|
|
27
|
+
import hmac
|
|
28
|
+
import ipaddress
|
|
29
|
+
import socket
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import urllib.request
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
|
|
35
|
+
from spanforge.exceptions import ExportError
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Sequence
|
|
39
|
+
|
|
40
|
+
from spanforge.event import Event
|
|
41
|
+
|
|
42
|
+
__all__ = ["WebhookExporter"]
|
|
43
|
+
|
|
44
|
+
# Header name for the HMAC-SHA256 request signature.
|
|
45
|
+
# Note: kept as the legacy value for backwards-compatibility with existing receivers.
|
|
46
|
+
_SIGNATURE_HEADER = "X-SpanForge-Signature"
|
|
47
|
+
|
|
48
|
+
# Maximum retry sleep (seconds) — hard ceiling regardless of attempt count.
|
|
49
|
+
_MAX_SLEEP: float = 30.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_private_ip_literal(host: str) -> bool:
|
|
53
|
+
"""Return ``True`` if *host* is a private/loopback/link-local **literal** IP.
|
|
54
|
+
|
|
55
|
+
.. warning::
|
|
56
|
+
**SSRF limitation** — DNS hostnames are **not** resolved. A hostname
|
|
57
|
+
such as ``"localhost"`` or ``"internal.corp"`` is *not* caught here.
|
|
58
|
+
The caller is responsible for additional DNS-based validation if needed.
|
|
59
|
+
Only dotted-decimal IPv4 or bracketed IPv6 **literals** are evaluated.
|
|
60
|
+
|
|
61
|
+
Set ``allow_private_addresses=True`` (config: ``allow_private_endpoints``)
|
|
62
|
+
to permit private IP literals in non-production environments.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
addr = ipaddress.ip_address(host)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return False # not an IP literal — treat as safe
|
|
68
|
+
return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_multicast
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _validate_http_url(
|
|
72
|
+
url: str,
|
|
73
|
+
param_name: str = "url",
|
|
74
|
+
*,
|
|
75
|
+
allow_private_addresses: bool = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Raise *ValueError* if *url* is not a valid ``http://`` or ``https://`` URL.
|
|
78
|
+
|
|
79
|
+
When *allow_private_addresses* is ``False`` (default), also rejects URLs
|
|
80
|
+
whose host is a literal private/loopback/link-local IP address. DNS
|
|
81
|
+
hostnames are **not** resolved so ``http://localhost/`` still passes.
|
|
82
|
+
"""
|
|
83
|
+
parsed = urllib.parse.urlparse(url)
|
|
84
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"{param_name} must be a valid http:// or https:// URL; got {url!r}"
|
|
87
|
+
)
|
|
88
|
+
if not allow_private_addresses:
|
|
89
|
+
host = parsed.hostname or ""
|
|
90
|
+
if _is_private_ip_literal(host):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"{param_name} resolves to a private/loopback/link-local IP address "
|
|
93
|
+
f"({host!r}). Set allow_private_addresses=True to permit this in "
|
|
94
|
+
f"non-production environments."
|
|
95
|
+
)
|
|
96
|
+
# DNS-based SSRF check — best-effort; DNS failure is non-fatal.
|
|
97
|
+
if host and not _is_private_ip_literal(host):
|
|
98
|
+
try:
|
|
99
|
+
resolved = socket.gethostbyname(host)
|
|
100
|
+
addr = ipaddress.ip_address(resolved)
|
|
101
|
+
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"{param_name} hostname {host!r} resolves to a private/loopback/"
|
|
104
|
+
f"link-local address ({resolved}). "
|
|
105
|
+
"Set allow_private_addresses=True to permit this."
|
|
106
|
+
)
|
|
107
|
+
except OSError: # DNS failure — allow through
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _sign_body(body: bytes, secret: str) -> str:
|
|
112
|
+
"""Compute ``hmac-sha256:<hex>`` signature for *body*.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
body: Raw request body bytes.
|
|
116
|
+
secret: HMAC secret string (UTF-8 encoded internally).
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Signature string in the form ``"hmac-sha256:<hexdigest>"``.
|
|
120
|
+
"""
|
|
121
|
+
mac = hmac.new(
|
|
122
|
+
secret.encode("utf-8"),
|
|
123
|
+
msg=body,
|
|
124
|
+
digestmod=hashlib.sha256,
|
|
125
|
+
)
|
|
126
|
+
return f"hmac-sha256:{mac.hexdigest()}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class WebhookExporter:
|
|
130
|
+
"""Async exporter that sends spanforge events to an HTTP webhook endpoint.
|
|
131
|
+
|
|
132
|
+
Each :meth:`export` call delivers a single event as the JSON body.
|
|
133
|
+
:meth:`export_batch` delivers a JSON array.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
url: Destination webhook URL.
|
|
137
|
+
secret: Optional HMAC-SHA256 signing secret. When provided, the
|
|
138
|
+
request includes an ``X-SpanForge-Signature`` header.
|
|
139
|
+
headers: Optional extra HTTP request headers.
|
|
140
|
+
timeout: Per-request timeout in seconds (default 10.0).
|
|
141
|
+
max_retries: Maximum retry attempts on transient failures (default 3).
|
|
142
|
+
Retries are attempted only for network errors and 5xx
|
|
143
|
+
responses. 4xx errors are not retried.
|
|
144
|
+
|
|
145
|
+
Security:
|
|
146
|
+
The ``secret`` is never included in ``repr()``, log messages, or
|
|
147
|
+
exception strings.
|
|
148
|
+
|
|
149
|
+
Example::
|
|
150
|
+
|
|
151
|
+
exporter = WebhookExporter(
|
|
152
|
+
url="https://hooks.example.com/events",
|
|
153
|
+
secret="my-hmac-secret",
|
|
154
|
+
)
|
|
155
|
+
await exporter.export(event)
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__( # noqa: PLR0913
|
|
159
|
+
self,
|
|
160
|
+
url: str,
|
|
161
|
+
*,
|
|
162
|
+
secret: str | None = None,
|
|
163
|
+
headers: dict[str, str] | None = None,
|
|
164
|
+
timeout: float = 10.0,
|
|
165
|
+
max_retries: int = 3,
|
|
166
|
+
allow_private_addresses: bool = False,
|
|
167
|
+
) -> None:
|
|
168
|
+
if not url:
|
|
169
|
+
raise ValueError("url must be a non-empty string")
|
|
170
|
+
_validate_http_url(url, "url", allow_private_addresses=allow_private_addresses)
|
|
171
|
+
if timeout <= 0:
|
|
172
|
+
raise ValueError("timeout must be positive")
|
|
173
|
+
if max_retries < 0:
|
|
174
|
+
raise ValueError("max_retries must be >= 0")
|
|
175
|
+
self._url = url
|
|
176
|
+
self._secret: str | None = secret
|
|
177
|
+
self._headers: dict[str, str] = dict(headers) if headers else {}
|
|
178
|
+
self._timeout = timeout
|
|
179
|
+
self._max_retries = max_retries
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Public async API
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async def export(self, event: Event) -> None:
|
|
186
|
+
"""Export a single event as a JSON-encoded HTTP POST.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
event: The event to deliver.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ExportError: If all retry attempts fail.
|
|
193
|
+
"""
|
|
194
|
+
body = event.to_json().encode("utf-8")
|
|
195
|
+
await self._post(body, event_id=event.event_id)
|
|
196
|
+
|
|
197
|
+
async def export_batch(self, events: Sequence[Event]) -> int:
|
|
198
|
+
"""Export multiple events as a JSON array in a single HTTP POST.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
events: Sequence of events to deliver.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Number of events sent.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ExportError: If all retry attempts fail.
|
|
208
|
+
"""
|
|
209
|
+
if not events:
|
|
210
|
+
return 0
|
|
211
|
+
array_json = (
|
|
212
|
+
"["
|
|
213
|
+
+ ",".join(e.to_json() for e in events)
|
|
214
|
+
+ "]"
|
|
215
|
+
)
|
|
216
|
+
body = array_json.encode("utf-8")
|
|
217
|
+
await self._post(body, event_id="")
|
|
218
|
+
return len(events)
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
# Internal helpers
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _do_http_post(url: str, body: bytes, headers: dict[str, str], timeout: float, event_id: str) -> None:
|
|
226
|
+
"""Execute a single HTTP POST; raises ExportError on failure."""
|
|
227
|
+
req = urllib.request.Request( # noqa: S310 # NOSONAR
|
|
228
|
+
url=url,
|
|
229
|
+
data=body,
|
|
230
|
+
headers=headers,
|
|
231
|
+
method="POST",
|
|
232
|
+
)
|
|
233
|
+
try:
|
|
234
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 # NOSONAR
|
|
235
|
+
resp.read()
|
|
236
|
+
except urllib.error.HTTPError as exc:
|
|
237
|
+
raise ExportError("webhook", f"HTTP {exc.code}: {exc.reason}", event_id) from exc
|
|
238
|
+
except OSError as exc:
|
|
239
|
+
raise ExportError("webhook", str(exc), event_id) from exc
|
|
240
|
+
|
|
241
|
+
async def _post(self, body: bytes, event_id: str) -> None:
|
|
242
|
+
"""POST *body* to :attr:`_url` with retry and optional signing.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
body: Raw request body bytes.
|
|
246
|
+
event_id: Event ID for error context (empty string for batches).
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
ExportError: After exhausting all retry attempts.
|
|
250
|
+
EgressViolationError: If the endpoint is blocked by egress policy.
|
|
251
|
+
"""
|
|
252
|
+
from spanforge.egress import check_egress # noqa: PLC0415
|
|
253
|
+
|
|
254
|
+
check_egress(self._url, backend="webhook")
|
|
255
|
+
|
|
256
|
+
request_headers: dict[str, str] = {
|
|
257
|
+
"Content-Type": "application/json",
|
|
258
|
+
**self._headers,
|
|
259
|
+
}
|
|
260
|
+
if self._secret is not None:
|
|
261
|
+
request_headers[_SIGNATURE_HEADER] = _sign_body(body, self._secret)
|
|
262
|
+
|
|
263
|
+
url = self._url
|
|
264
|
+
timeout = self._timeout
|
|
265
|
+
last_exc: ExportError | None = None
|
|
266
|
+
|
|
267
|
+
for attempt in range(self._max_retries + 1):
|
|
268
|
+
if attempt > 0:
|
|
269
|
+
# Truncated exponential back-off: 1s, 2s, 4s … capped at 30s.
|
|
270
|
+
sleep_secs = min(2 ** (attempt - 1), _MAX_SLEEP)
|
|
271
|
+
await asyncio.sleep(sleep_secs)
|
|
272
|
+
|
|
273
|
+
loop = asyncio.get_running_loop()
|
|
274
|
+
# Capture the current contextvars context so it is propagated into
|
|
275
|
+
# the executor thread (fixes contextvar loss via run_in_executor).
|
|
276
|
+
ctx = contextvars.copy_context()
|
|
277
|
+
try:
|
|
278
|
+
await loop.run_in_executor(
|
|
279
|
+
None,
|
|
280
|
+
lambda: ctx.run(self._do_http_post, url, body, request_headers, timeout, event_id),
|
|
281
|
+
)
|
|
282
|
+
except ExportError as exc:
|
|
283
|
+
last_exc = exc
|
|
284
|
+
if exc.reason.startswith("HTTP 4"):
|
|
285
|
+
raise
|
|
286
|
+
else:
|
|
287
|
+
return # success
|
|
288
|
+
|
|
289
|
+
assert last_exc is not None # always set when we reach here
|
|
290
|
+
raise last_exc
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Repr — secret intentionally omitted
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def __repr__(self) -> str:
|
|
297
|
+
has_secret = self._secret is not None
|
|
298
|
+
return (
|
|
299
|
+
f"WebhookExporter(url={self._url!r}, "
|
|
300
|
+
f"signed={has_secret!r}, "
|
|
301
|
+
f"max_retries={self._max_retries!r})"
|
|
302
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""spanforge.exporters — Synchronous export backends for the SpanForge SDK.
|
|
2
|
+
|
|
3
|
+
This package provides the sync exporter implementations used by the internal
|
|
4
|
+
:mod:`spanforge._stream` module. All exporters expose the same minimal
|
|
5
|
+
interface::
|
|
6
|
+
|
|
7
|
+
class SomeExporter:
|
|
8
|
+
def export(self, event: Event) -> None: ...
|
|
9
|
+
def flush(self) -> None: ...
|
|
10
|
+
def close(self) -> None: ...
|
|
11
|
+
|
|
12
|
+
Available exporters
|
|
13
|
+
-------------------
|
|
14
|
+
* :class:`~spanforge.exporters.jsonl.SyncJSONLExporter` — append events as
|
|
15
|
+
newline-delimited JSON to a file.
|
|
16
|
+
* :class:`~spanforge.exporters.console.SyncConsoleExporter` — pretty-print
|
|
17
|
+
events to stdout during development.
|
|
18
|
+
|
|
19
|
+
Additional backends (OTLP, Webhook, Datadog, Grafana Loki) are implemented in
|
|
20
|
+
later phases; they live in :mod:`spanforge.export` (the async-based backends) until
|
|
21
|
+
synchronous wrappers are added here.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from spanforge.exporters.console import SyncConsoleExporter
|
|
27
|
+
from spanforge.exporters.jsonl import SyncJSONLExporter
|
|
28
|
+
|
|
29
|
+
__all__ = ["SyncConsoleExporter", "SyncJSONLExporter"]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""spanforge.exporters.console — Human-readable development console exporter.
|
|
2
|
+
|
|
3
|
+
Prints a formatted summary box to ``sys.stdout`` each time a span or agent
|
|
4
|
+
event is emitted. Designed for rapid development feedback — no file is written,
|
|
5
|
+
no external dependencies are required.
|
|
6
|
+
|
|
7
|
+
Example output (with colour support)::
|
|
8
|
+
|
|
9
|
+
╔══ span: chat [gpt-4o] ══════════════════════════════╗
|
|
10
|
+
║ event_id : 01JXXXXXXXXXXXXXXXXXXXXXXX
|
|
11
|
+
║ trace_id : 01JXXXXXXXXXXXXXXXXXXXXXXX
|
|
12
|
+
║ duration : 142.3ms
|
|
13
|
+
║ tokens : in=512 out=128 total=640
|
|
14
|
+
║ cost : $0.00096
|
|
15
|
+
║ status : ok
|
|
16
|
+
╚═════════════════════════════════════════════════════╝
|
|
17
|
+
|
|
18
|
+
Colour is enabled automatically when stdout is a TTY. Set the ``NO_COLOR``
|
|
19
|
+
environment variable (any value) to force plain text output per the
|
|
20
|
+
`no-color.org <https://no-color.org>`_ convention.
|
|
21
|
+
|
|
22
|
+
Usage::
|
|
23
|
+
|
|
24
|
+
from spanforge import configure
|
|
25
|
+
configure(exporter="console")
|
|
26
|
+
|
|
27
|
+
Zero external dependencies — stdlib only (``os``, ``sys``).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from spanforge.event import Event
|
|
39
|
+
|
|
40
|
+
__all__ = ["SyncConsoleExporter"]
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Colour helpers
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
# ANSI escape codes
|
|
47
|
+
_RESET = "\x1b[0m"
|
|
48
|
+
_BOLD = "\x1b[1m"
|
|
49
|
+
_DIM = "\x1b[2m"
|
|
50
|
+
_CYAN = "\x1b[36m"
|
|
51
|
+
_GREEN = "\x1b[32m"
|
|
52
|
+
_RED = "\x1b[31m"
|
|
53
|
+
_YELLOW = "\x1b[33m"
|
|
54
|
+
_MAGENTA = "\x1b[35m"
|
|
55
|
+
_BLUE = "\x1b[34m"
|
|
56
|
+
_WHITE = "\x1b[97m"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _use_colour() -> bool:
|
|
60
|
+
"""Return ``True`` if ANSI colour should be emitted."""
|
|
61
|
+
if os.environ.get("NO_COLOR"):
|
|
62
|
+
return False
|
|
63
|
+
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _c(text: str, *codes: str) -> str:
|
|
67
|
+
"""Wrap *text* with ANSI *codes* if colour is enabled, else return plain."""
|
|
68
|
+
if not _use_colour():
|
|
69
|
+
return text
|
|
70
|
+
return "".join(codes) + text + _RESET
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Box-drawing characters
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
_BOX_WIDTH = 56 # inner width (chars between ╔ and ╗)
|
|
78
|
+
_MIN_NAMESPACE_PARTS = 2 # minimum dot-separated parts for namespace extraction
|
|
79
|
+
|
|
80
|
+
_TL = "╔" # top-left corner
|
|
81
|
+
_TR = "╗" # top-right corner
|
|
82
|
+
_BL = "╚" # bottom-left corner
|
|
83
|
+
_BR = "╝" # bottom-right corner
|
|
84
|
+
_H = "═" # horizontal
|
|
85
|
+
_V = "║" # vertical
|
|
86
|
+
_TJ = "╤" # top T-junction (unused, reserved)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hline(char: str = _H) -> str:
|
|
90
|
+
return char * _BOX_WIDTH
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _top_bar(title: str) -> str:
|
|
94
|
+
"""``╔══ <title> ═════╗`` with padding."""
|
|
95
|
+
inner = f"══ {title} "
|
|
96
|
+
pad = _BOX_WIDTH - len(inner)
|
|
97
|
+
pad = max(pad, 2)
|
|
98
|
+
return _c(_TL + inner + _H * pad + _TR, _CYAN, _BOLD)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _bottom_bar() -> str:
|
|
102
|
+
return _c(_BL + _hline() + _BR, _CYAN, _BOLD)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _row(label: str, value: str, value_colour: str = "") -> str:
|
|
106
|
+
label_part = _c(f" {label:<12}", _DIM)
|
|
107
|
+
colon = _c(": ", _DIM)
|
|
108
|
+
val_part = _c(value, value_colour) if value_colour else value
|
|
109
|
+
return _c(_V, _CYAN, _BOLD) + label_part + colon + val_part
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Payload extractors
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get(payload: dict, *keys: str, default: str = "") -> str:
|
|
118
|
+
"""Safely retrieve a nested value from *payload* as a string.
|
|
119
|
+
|
|
120
|
+
Works with both plain ``dict`` and read-only ``MappingProxyType`` objects
|
|
121
|
+
(the latter is how :class:`~spanforge.event.Event` stores its payload).
|
|
122
|
+
"""
|
|
123
|
+
obj: object = payload
|
|
124
|
+
for key in keys:
|
|
125
|
+
if not isinstance(obj, Mapping):
|
|
126
|
+
return default
|
|
127
|
+
obj = obj.get(key) # type: ignore[union-attr]
|
|
128
|
+
if obj is None:
|
|
129
|
+
return default
|
|
130
|
+
return str(obj)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _format_tokens(payload: dict) -> str | None:
|
|
134
|
+
tu = payload.get("token_usage")
|
|
135
|
+
if not isinstance(tu, dict):
|
|
136
|
+
return None
|
|
137
|
+
i = tu.get("input_tokens", "?")
|
|
138
|
+
o = tu.get("output_tokens", "?")
|
|
139
|
+
t = tu.get("total_tokens", "?")
|
|
140
|
+
return f"in={i} out={o} total={t}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _format_cost(payload: dict) -> str | None:
|
|
144
|
+
cost = payload.get("cost")
|
|
145
|
+
if not isinstance(cost, dict):
|
|
146
|
+
return None
|
|
147
|
+
total = cost.get("total_cost_usd")
|
|
148
|
+
if total is None:
|
|
149
|
+
return None
|
|
150
|
+
currency = cost.get("currency", "USD")
|
|
151
|
+
if currency == "USD":
|
|
152
|
+
return f"${total:.5f}"
|
|
153
|
+
return f"{total:.5f} {currency}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _format_duration(payload: dict) -> str | None:
|
|
157
|
+
ms = payload.get("duration_ms")
|
|
158
|
+
if ms is None:
|
|
159
|
+
return None
|
|
160
|
+
return f"{float(ms):.1f}ms"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _status_colour(status: str) -> str:
|
|
164
|
+
if status == "ok":
|
|
165
|
+
return _GREEN
|
|
166
|
+
if status in ("error", "timeout"):
|
|
167
|
+
return _RED
|
|
168
|
+
return _YELLOW
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Main formatter
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _format_event_rows(event: Event, payload: dict[str, object], lines: list[str]) -> None:
|
|
177
|
+
"""Append formatted detail rows to *lines* for the given event."""
|
|
178
|
+
et = event.event_type
|
|
179
|
+
|
|
180
|
+
lines.append(_row("event_id", event.event_id, _BLUE))
|
|
181
|
+
lines.append(_row("event_type", et))
|
|
182
|
+
|
|
183
|
+
trace_id = event.trace_id or _get(payload, "trace_id")
|
|
184
|
+
if trace_id:
|
|
185
|
+
lines.append(_row("trace_id", trace_id, _MAGENTA))
|
|
186
|
+
span_id = event.span_id or _get(payload, "span_id")
|
|
187
|
+
if span_id:
|
|
188
|
+
lines.append(_row("span_id", span_id, _MAGENTA))
|
|
189
|
+
|
|
190
|
+
dur = _format_duration(payload)
|
|
191
|
+
if dur:
|
|
192
|
+
lines.append(_row("duration", dur, _CYAN))
|
|
193
|
+
|
|
194
|
+
tokens = _format_tokens(payload)
|
|
195
|
+
if tokens:
|
|
196
|
+
lines.append(_row("tokens", tokens, _WHITE))
|
|
197
|
+
|
|
198
|
+
cost_str = _format_cost(payload)
|
|
199
|
+
if cost_str:
|
|
200
|
+
lines.append(_row("cost", cost_str, _YELLOW))
|
|
201
|
+
|
|
202
|
+
status = payload.get("status", "ok")
|
|
203
|
+
if isinstance(status, str):
|
|
204
|
+
lines.append(_row("status", status, _status_colour(status)))
|
|
205
|
+
|
|
206
|
+
error_msg = payload.get("error")
|
|
207
|
+
if error_msg:
|
|
208
|
+
lines.append(_row("error", str(error_msg), _RED))
|
|
209
|
+
|
|
210
|
+
if "total_steps" in payload:
|
|
211
|
+
lines.append(_row("steps", str(payload["total_steps"])))
|
|
212
|
+
if "step_index" in payload:
|
|
213
|
+
lines.append(_row("step_index", str(payload["step_index"])))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _format_event(event: Event) -> str:
|
|
217
|
+
"""Render *event* as a multi-line console box string."""
|
|
218
|
+
payload = event.payload or {}
|
|
219
|
+
et = event.event_type # e.g. "llm.trace.span.completed"
|
|
220
|
+
|
|
221
|
+
namespace_part = et.split(".")[2] if et.count(".") >= _MIN_NAMESPACE_PARTS else "trace"
|
|
222
|
+
span_name = (
|
|
223
|
+
payload.get("span_name")
|
|
224
|
+
or payload.get("agent_name")
|
|
225
|
+
or payload.get("step_name")
|
|
226
|
+
or "unknown"
|
|
227
|
+
)
|
|
228
|
+
model_name = _get(payload, "model", "name")
|
|
229
|
+
model_suffix = f" [{model_name}]" if model_name else ""
|
|
230
|
+
title = f"{namespace_part}: {span_name}{model_suffix}"
|
|
231
|
+
|
|
232
|
+
lines: list[str] = [_top_bar(title)]
|
|
233
|
+
_format_event_rows(event, payload, lines)
|
|
234
|
+
lines.append(_bottom_bar())
|
|
235
|
+
return "\n".join(lines) + "\n"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Exporter class
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class SyncConsoleExporter:
|
|
244
|
+
"""Synchronous exporter that pretty-prints events to ``sys.stdout``.
|
|
245
|
+
|
|
246
|
+
No file is written; output goes to ``sys.stdout`` only. ANSI colour
|
|
247
|
+
codes are emitted when stdout is a TTY and ``NO_COLOR`` is not set.
|
|
248
|
+
|
|
249
|
+
This exporter is the default when ``configure(exporter="console")`` is
|
|
250
|
+
used, which is the default if ``SPANFORGE_EXPORTER`` is not set.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def export(self, event: Event) -> None:
|
|
254
|
+
"""Print *event* as a formatted box to ``sys.stdout``.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
event: A :class:`~spanforge.event.Event` instance.
|
|
258
|
+
"""
|
|
259
|
+
formatted = _format_event(event)
|
|
260
|
+
sys.stdout.write(formatted)
|
|
261
|
+
sys.stdout.flush()
|
|
262
|
+
|
|
263
|
+
def flush(self) -> None:
|
|
264
|
+
"""Flush stdout."""
|
|
265
|
+
sys.stdout.flush()
|
|
266
|
+
|
|
267
|
+
def close(self) -> None:
|
|
268
|
+
"""No-op — console exporter has no resources to release."""
|
|
269
|
+
|
|
270
|
+
def __repr__(self) -> str:
|
|
271
|
+
return "SyncConsoleExporter()"
|