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
@@ -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()"