spanforge 1.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 (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
@@ -0,0 +1,320 @@
1
+ """spanforge.export.grafana — Grafana Loki log exporter.
2
+
3
+ Pushes SpanForge events to a **Grafana Loki** instance through the
4
+ ``/loki/api/v1/push`` endpoint.
5
+
6
+ Transport
7
+ ---------
8
+ Uses :func:`urllib.request.urlopen` in a thread-pool executor so the async
9
+ event loop is never blocked. The request body is JSON-encoded following the
10
+ Loki push API v1. No external dependencies are required.
11
+
12
+ Stream labels
13
+ -------------
14
+ By default each entry is tagged with:
15
+
16
+ * ``event_type`` — the dot-separated event type, with dots replaced by
17
+ underscores so the value is a legal Prometheus label value.
18
+ * ``org_id`` — ``event.org_id`` (if present).
19
+ * any user-supplied global *labels* passed to the constructor.
20
+
21
+ Set ``include_envelope_labels=False`` to suppress the ``event_type`` and
22
+ ``org_id`` fields from the stream labels.
23
+
24
+ Usage::
25
+
26
+ from spanforge.export.grafana import GrafanaLokiExporter
27
+
28
+ exporter = GrafanaLokiExporter(
29
+ url="http://localhost:3100", # NOSONAR
30
+ labels={"app": "my-llm-service"},
31
+ tenant_id="my-org",
32
+ )
33
+ await exporter.export(event)
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import ipaddress
40
+ import json
41
+ import socket
42
+ import urllib.error
43
+ import urllib.parse
44
+ import urllib.request
45
+ from datetime import datetime, timezone
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ from spanforge.exceptions import ExportError
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import Sequence
52
+
53
+ from spanforge.event import Event
54
+
55
+ __all__ = [
56
+ "GrafanaLokiExporter",
57
+ ]
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Helpers
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _is_private_ip_literal(host: str) -> bool:
65
+ try:
66
+ addr = ipaddress.ip_address(host)
67
+ except ValueError:
68
+ return False
69
+ return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_multicast
70
+
71
+
72
+ def _validate_http_url(
73
+ url: str,
74
+ param_name: str = "url",
75
+ *,
76
+ allow_private_addresses: bool = False,
77
+ ) -> None:
78
+ parsed = urllib.parse.urlparse(url)
79
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
80
+ raise ValueError(f"{param_name} must be a valid http:// or https:// URL; got {url!r}")
81
+ if not allow_private_addresses:
82
+ host = parsed.hostname or ""
83
+ if _is_private_ip_literal(host):
84
+ raise ValueError(
85
+ f"{param_name} resolves to a private/loopback/link-local IP address "
86
+ f"({host!r}). Set allow_private_addresses=True to permit this."
87
+ )
88
+ # DNS-based SSRF check — best-effort; DNS failure is non-fatal.
89
+ if host and not _is_private_ip_literal(host):
90
+ try:
91
+ resolved = socket.gethostbyname(host)
92
+ addr = ipaddress.ip_address(resolved)
93
+ if addr.is_private or addr.is_loopback or addr.is_link_local:
94
+ raise ValueError(
95
+ f"{param_name} hostname {host!r} resolves to a private/loopback/"
96
+ f"link-local address ({resolved}). "
97
+ "Set allow_private_addresses=True to permit this."
98
+ )
99
+ except OSError: # DNS failure — allow through
100
+ pass
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Main exporter
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class GrafanaLokiExporter:
109
+ """Async exporter that ships SpanForge events to Grafana Loki.
110
+
111
+ Args:
112
+ url: Loki base URL (e.g. ``"http://localhost:3100"``).
113
+ labels: Global stream labels applied to every entry.
114
+ timeout: Per-request timeout in seconds (default 10.0).
115
+ tenant_id: When set, included in ``X-Scope-OrgID`` header.
116
+ include_envelope_labels: Whether to add ``event_type`` and ``org_id``
117
+ from the event envelope to the stream labels
118
+ (default ``True``).
119
+
120
+ Raises:
121
+ ValueError: If *url* is not a valid HTTP/HTTPS URL or *timeout* is not
122
+ positive.
123
+ """
124
+
125
+ def __init__(
126
+ self,
127
+ url: str,
128
+ *,
129
+ labels: dict[str, str] | None = None,
130
+ timeout: float = 10.0,
131
+ tenant_id: str | None = None,
132
+ include_envelope_labels: bool = True,
133
+ allow_private_addresses: bool = False,
134
+ ) -> None:
135
+ if timeout <= 0:
136
+ raise ValueError("timeout must be positive")
137
+ _validate_http_url(url, "url", allow_private_addresses=allow_private_addresses)
138
+
139
+ self._base_url = url.rstrip("/")
140
+ self._global_labels: dict[str, str] = dict(labels or {})
141
+ self._timeout = timeout
142
+ self._tenant_id: str | None = tenant_id
143
+ self._include_envelope_labels = include_envelope_labels
144
+
145
+ # ------------------------------------------------------------------
146
+ # Public conversion API
147
+ # ------------------------------------------------------------------
148
+
149
+ def event_to_loki_entry(self, event: Event) -> dict[str, Any]:
150
+ """Convert a SpanForge :class:`~spanforge.event.Event` to a Loki log entry dict.
151
+
152
+ The returned dict has shape::
153
+
154
+ {
155
+ "stream": {"key": "value", ...},
156
+ "values": [["<nanoseconds>", "<json payload>"]],
157
+ }
158
+
159
+ Args:
160
+ event: The event to convert.
161
+
162
+ Returns:
163
+ A dict ready to be included in a Loki push request.
164
+ """
165
+ # Build stream labels
166
+ stream: dict[str, str] = {}
167
+
168
+ if self._include_envelope_labels:
169
+ # Replace dots with underscores — Loki label values are Prometheus labels
170
+ event_type_label = str(event.event_type).replace(".", "_")
171
+ stream["event_type"] = event_type_label
172
+ if event.org_id:
173
+ stream["org_id"] = event.org_id
174
+
175
+ # User-supplied global labels come last (may override envelope labels)
176
+ stream.update(self._global_labels)
177
+
178
+ # Build the log line (JSON)
179
+ try:
180
+ line = event.to_json()
181
+ except Exception: # NOSONAR
182
+ line = json.dumps(
183
+ {
184
+ "event_id": str(getattr(event, "event_id", "")),
185
+ "event_type": str(event.event_type),
186
+ "timestamp": event.timestamp,
187
+ }
188
+ )
189
+
190
+ # Timestamp in nanoseconds as string
191
+ ns_str = str(self._iso_to_ns(event.timestamp))
192
+
193
+ return {
194
+ "stream": stream,
195
+ "values": [[ns_str, line]],
196
+ }
197
+
198
+ @staticmethod
199
+ def _iso_to_ns(ts: str) -> int:
200
+ """Convert an ISO-8601 timestamp string to nanoseconds since Unix epoch.
201
+
202
+ Args:
203
+ ts: ISO-8601 datetime string (e.g. ``"2024-01-15T12:00:00.000000Z"``).
204
+
205
+ Returns:
206
+ Integer nanoseconds since the Unix epoch.
207
+ """
208
+ try:
209
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
210
+ if dt.tzinfo is None:
211
+ dt = dt.replace(tzinfo=timezone.utc)
212
+ return int(dt.timestamp() * 1_000_000_000)
213
+ except ValueError as exc:
214
+ raise ExportError(
215
+ "grafana_loki",
216
+ f"cannot parse event timestamp {ts!r}: {exc}",
217
+ ) from exc
218
+
219
+ # ------------------------------------------------------------------
220
+ # Async export API
221
+ # ------------------------------------------------------------------
222
+
223
+ async def export(self, event: Event) -> None:
224
+ """Export a single event to Grafana Loki.
225
+
226
+ Args:
227
+ event: The event to export.
228
+
229
+ Raises:
230
+ ExportError: On HTTP or network errors.
231
+ """
232
+ await self.export_batch([event])
233
+
234
+ async def export_batch(self, events: Sequence[Event]) -> int:
235
+ """Export multiple events to Grafana Loki.
236
+
237
+ Events that share identical stream labels are grouped into the same
238
+ Loki stream to reduce push requests.
239
+
240
+ Args:
241
+ events: Sequence of events to deliver.
242
+
243
+ Returns:
244
+ Number of events successfully submitted.
245
+
246
+ Raises:
247
+ ExportError: On HTTP or network errors.
248
+ """
249
+ if not events:
250
+ return 0
251
+
252
+ # Group by frozenset of stream label items
253
+ groups: dict[Any, tuple[dict[str, str], list[list[str]]]] = {}
254
+ for event in events:
255
+ entry = self.event_to_loki_entry(event)
256
+ stream = entry["stream"]
257
+ key = frozenset(stream.items())
258
+ if key not in groups:
259
+ groups[key] = (stream, [])
260
+ groups[key][1].extend(entry["values"])
261
+
262
+ streams: list[dict[str, Any]] = [
263
+ {"stream": stream_labels, "values": values}
264
+ for (stream_labels, values) in groups.values()
265
+ ]
266
+
267
+ payload = json.dumps({"streams": streams}).encode("utf-8")
268
+ await self._push(payload)
269
+ return len(events)
270
+
271
+ # ------------------------------------------------------------------
272
+ # Internal HTTP helpers
273
+ # ------------------------------------------------------------------
274
+
275
+ async def _push(self, payload: bytes) -> None:
276
+ """Push a serialised Loki request body to the ingest endpoint.
277
+
278
+ Args:
279
+ payload: JSON-encoded bytes to POST.
280
+
281
+ Raises:
282
+ ExportError: On HTTP or network failure.
283
+ """
284
+ await asyncio.get_event_loop().run_in_executor(None, lambda: self._do_push(payload))
285
+
286
+ def _do_push(self, body: bytes) -> None:
287
+ """Perform a synchronous HTTP POST to ``/loki/api/v1/push`` (called from executor).
288
+
289
+ Args:
290
+ body: Request body bytes.
291
+
292
+ Raises:
293
+ ExportError: On HTTP or network failure.
294
+ EgressViolationError: If the endpoint is blocked by egress policy.
295
+ """
296
+ from spanforge.egress import check_egress
297
+
298
+ url = f"{self._base_url}/loki/api/v1/push"
299
+ check_egress(url, backend="grafana-loki")
300
+ headers: dict[str, str] = {
301
+ "Content-Type": "application/json",
302
+ }
303
+ if self._tenant_id:
304
+ headers["X-Scope-OrgID"] = self._tenant_id
305
+
306
+ req = urllib.request.Request(url=url, data=body, headers=headers, method="POST") # NOSONAR
307
+ try:
308
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # nosec B310
309
+ resp.read()
310
+ except urllib.error.HTTPError as exc:
311
+ raise ExportError("grafana-loki", f"HTTP {exc.code} from {url}: {exc.reason}") from exc
312
+ except OSError as exc:
313
+ raise ExportError("grafana-loki", f"network error posting to {url}: {exc}") from exc
314
+
315
+ # ------------------------------------------------------------------
316
+ # dunder
317
+ # ------------------------------------------------------------------
318
+
319
+ def __repr__(self) -> str:
320
+ return f"GrafanaLokiExporter(url={self._base_url!r}, tenant_id={self._tenant_id!r})"
@@ -0,0 +1,195 @@
1
+ """JSONL (newline-delimited JSON) file exporter for spanforge events.
2
+
3
+ Ideal for local development, integration tests, and building tamper-evident
4
+ audit trails that can be loaded back via
5
+ :meth:`~spanforge.stream.EventStream.from_file`.
6
+
7
+ Features
8
+ --------
9
+ * Appends one JSON line per event — safe for append-only audit storage.
10
+ * ``path="-"`` writes to *stdout* (useful for log pipelines).
11
+ * Async-safe: an :class:`asyncio.Lock` serialises concurrent appends so the
12
+ file is never corrupted even when multiple coroutines share one exporter.
13
+ * Acts as an async context manager: ``async with JSONLExporter(...) as e:``.
14
+ * :meth:`flush` and :meth:`close` are safe to call multiple times.
15
+
16
+ Example::
17
+
18
+ async with JSONLExporter("events.jsonl") as exporter:
19
+ for event in events:
20
+ await exporter.export(event)
21
+
22
+ # Read back with EventStream
23
+ from spanforge.stream import EventStream
24
+ stream = EventStream.from_file("events.jsonl")
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import IO, TYPE_CHECKING, Union
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Sequence
36
+
37
+ from spanforge.event import Event
38
+
39
+ __all__ = ["JSONLExporter"]
40
+
41
+ _PathLike = Union[str, Path]
42
+
43
+
44
+ class JSONLExporter:
45
+ """Async exporter that appends events as newline-delimited JSON.
46
+
47
+ Args:
48
+ path: File path, :class:`pathlib.Path`, or ``"-"`` for stdout.
49
+ mode: File open mode — ``"a"`` (append, default) or ``"w"``
50
+ (overwrite / truncate).
51
+ encoding: File encoding (default ``"utf-8"``).
52
+
53
+ Thread / coroutine safety:
54
+ Concurrent calls to :meth:`export` or :meth:`export_batch` are
55
+ serialised with an :class:`asyncio.Lock`; the output file is never
56
+ written by more than one coroutine at a time.
57
+
58
+ Raises:
59
+ OSError: If the file cannot be opened or written.
60
+
61
+ Example::
62
+
63
+ exporter = JSONLExporter("audit.jsonl")
64
+ await exporter.export(event)
65
+ await exporter.close()
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ path: _PathLike | str,
71
+ mode: str = "a",
72
+ encoding: str = "utf-8",
73
+ ) -> None:
74
+ if mode not in ("a", "w"):
75
+ raise ValueError("mode must be 'a' or 'w'")
76
+ self._path_str = str(path)
77
+ self._mode = mode
78
+ self._encoding = encoding
79
+ self._file: IO[str] | None = None
80
+ self._lock: asyncio.Lock = asyncio.Lock()
81
+ self._closed: bool = False
82
+
83
+ # ------------------------------------------------------------------
84
+ # Internal helpers
85
+ # ------------------------------------------------------------------
86
+
87
+ def _ensure_open(self) -> IO[str]:
88
+ """Open the file handle if not already open.
89
+
90
+ Returns:
91
+ The open file handle.
92
+
93
+ Raises:
94
+ RuntimeError: If the exporter has been closed.
95
+ """
96
+ if self._closed:
97
+ raise RuntimeError("JSONLExporter has been closed")
98
+ if self._file is None:
99
+ if self._path_str == "-":
100
+ self._file = sys.stdout
101
+ else:
102
+ self._file = Path(self._path_str).open(
103
+ mode=self._mode,
104
+ encoding=self._encoding,
105
+ )
106
+ return self._file
107
+
108
+ # ------------------------------------------------------------------
109
+ # Async export API
110
+ # ------------------------------------------------------------------
111
+
112
+ async def export(self, event: Event) -> None:
113
+ """Append a single event as one JSON line.
114
+
115
+ Args:
116
+ event: The event to write.
117
+
118
+ Raises:
119
+ RuntimeError: If the exporter has been closed.
120
+ OSError: If the write fails.
121
+ """
122
+ async with self._lock:
123
+ fh = self._ensure_open()
124
+ fh.write(event.to_json())
125
+ fh.write("\n")
126
+
127
+ async def export_batch(self, events: Sequence[Event]) -> int:
128
+ """Append multiple events, one JSON line each.
129
+
130
+ Args:
131
+ events: Sequence of events to write.
132
+
133
+ Returns:
134
+ Number of events written.
135
+
136
+ Raises:
137
+ RuntimeError: If the exporter has been closed.
138
+ OSError: If the write fails.
139
+ """
140
+ if not events:
141
+ return 0
142
+ async with self._lock:
143
+ fh = self._ensure_open()
144
+ for event in events:
145
+ fh.write(event.to_json())
146
+ fh.write("\n")
147
+ return len(events)
148
+
149
+ # ------------------------------------------------------------------
150
+ # Flush / close
151
+ # ------------------------------------------------------------------
152
+
153
+ def flush(self) -> None:
154
+ """Flush internal write buffers to the OS.
155
+
156
+ Safe to call when no file is open yet. Does nothing if writing to
157
+ stdout (which is managed externally).
158
+ """
159
+ if self._file is not None and self._file is not sys.stdout:
160
+ self._file.flush()
161
+
162
+ def close(self) -> None:
163
+ """Flush and close the underlying file handle.
164
+
165
+ Idempotent — safe to call multiple times. Does not close stdout even
166
+ when ``path="-"`` was used.
167
+ """
168
+ if self._closed:
169
+ return
170
+ self._closed = True
171
+ if self._file is not None and self._file is not sys.stdout:
172
+ try:
173
+ self._file.flush()
174
+ finally:
175
+ self._file.close()
176
+ self._file = None
177
+
178
+ # ------------------------------------------------------------------
179
+ # Async context manager
180
+ # ------------------------------------------------------------------
181
+
182
+ async def __aenter__(self) -> JSONLExporter:
183
+ """Enter the async context manager — opens the file lazily."""
184
+ return self
185
+
186
+ async def __aexit__(self, *_: object) -> None:
187
+ """Exit the async context manager — flushes and closes the file."""
188
+ self.close()
189
+
190
+ # ------------------------------------------------------------------
191
+ # Repr
192
+ # ------------------------------------------------------------------
193
+
194
+ def __repr__(self) -> str:
195
+ return f"JSONLExporter(path={self._path_str!r}, mode={self._mode!r})"
@@ -0,0 +1,158 @@
1
+ """spanforge.export.openinference - OpenInference-compatible span translation.
2
+
3
+ This bridge follows the OpenInference semantic conventions for the subset of
4
+ fields SpanForge already captures reliably today. It is intended to provide an
5
+ interoperable export path on top of existing SpanForge traces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from spanforge._span import Span
15
+
16
+ __all__ = ["OpenInferenceSpanBridge", "span_to_openinference_dict"]
17
+
18
+
19
+ def span_to_openinference_dict(span: Span) -> dict[str, Any]:
20
+ """Translate a SpanForge span into an OpenInference-style span dict."""
21
+ attrs = dict(span.attributes or {})
22
+ oi_attrs: dict[str, Any] = {}
23
+ oi_attrs["openinference.span.kind"] = _span_kind(span)
24
+ oi_attrs["metadata"] = json.dumps(attrs, sort_keys=True, default=str)
25
+ if span.session_id:
26
+ oi_attrs["session.id"] = span.session_id
27
+
28
+ input_value = _first_string(
29
+ attrs,
30
+ "input.value",
31
+ "arg.input",
32
+ "input",
33
+ "prompt",
34
+ "query",
35
+ "message",
36
+ )
37
+ output_value = _first_string(
38
+ attrs,
39
+ "output.value",
40
+ "return_value",
41
+ "output",
42
+ "result",
43
+ )
44
+ if input_value is not None:
45
+ oi_attrs["input.value"] = input_value
46
+ if output_value is not None:
47
+ oi_attrs["output.value"] = output_value
48
+
49
+ _populate_model_attrs(span, oi_attrs)
50
+ _populate_token_attrs(span, oi_attrs)
51
+ _populate_cost_attrs(span, oi_attrs)
52
+
53
+ if span.error:
54
+ oi_attrs["exception.message"] = span.error
55
+ if span.error_type:
56
+ oi_attrs["exception.type"] = span.error_type
57
+ if span.error or span.error_type:
58
+ oi_attrs["exception.escaped"] = True
59
+
60
+ return {
61
+ "name": span.name,
62
+ "context": {
63
+ "trace_id": span.trace_id,
64
+ "span_id": span.span_id,
65
+ "parent_span_id": span.parent_span_id,
66
+ },
67
+ "status": "ERROR" if span.status == "error" else "OK",
68
+ "start_time_unix_nano": str(span.start_ns),
69
+ "end_time_unix_nano": str(span.end_ns or span.start_ns),
70
+ "attributes": oi_attrs,
71
+ }
72
+
73
+
74
+ @dataclass
75
+ class OpenInferenceSpanBridge:
76
+ """Build OpenInference-compatible traces from SpanForge spans."""
77
+
78
+ def to_spans(self, spans: list[Span]) -> list[dict[str, Any]]:
79
+ return [span_to_openinference_dict(span) for span in spans]
80
+
81
+ def to_trace(self, spans: list[Span]) -> dict[str, Any]:
82
+ return {"spans": self.to_spans(spans)}
83
+
84
+
85
+ def _span_kind(span: Span) -> str:
86
+ attrs = span.attributes or {}
87
+ if bool(attrs.get("tool")) or span.tool_calls:
88
+ return "TOOL"
89
+ op = str(span.operation or "").lower()
90
+ name = str(span.name or "").lower()
91
+ if "agent" in op or "agent" in name:
92
+ return "AGENT"
93
+ if "retriev" in op or "retriev" in name or "rag" in op:
94
+ return "RETRIEVER"
95
+ if span.model or "gen_ai.system" in attrs or "llm.system" in attrs:
96
+ return "LLM"
97
+ return "CHAIN"
98
+
99
+
100
+ def _populate_model_attrs(span: Span, attrs: dict[str, Any]) -> None:
101
+ payload = span.to_span_payload()
102
+ system = _first_string(span.attributes or {}, "llm.system", "gen_ai.system")
103
+ model_name = _first_string(span.attributes or {}, "llm.model_name", "model")
104
+ if payload.model is not None:
105
+ system = (
106
+ payload.model.system.value
107
+ if hasattr(payload.model.system, "value")
108
+ else str(payload.model.system)
109
+ )
110
+ model_name = payload.model.name
111
+ elif span.model:
112
+ model_name = span.model
113
+
114
+ if system is not None:
115
+ attrs["llm.system"] = system
116
+ if model_name is not None:
117
+ attrs["llm.model_name"] = model_name
118
+
119
+ provider = _first_string(span.attributes or {}, "llm.provider", "provider")
120
+ if provider is not None:
121
+ attrs["llm.provider"] = provider
122
+
123
+
124
+ def _populate_token_attrs(span: Span, attrs: dict[str, Any]) -> None:
125
+ if span.token_usage is None:
126
+ return
127
+ attrs["llm.token_count.prompt"] = span.token_usage.input_tokens
128
+ attrs["llm.token_count.completion"] = span.token_usage.output_tokens
129
+ attrs["llm.token_count.total"] = span.token_usage.total_tokens
130
+ if span.token_usage.cached_tokens is not None:
131
+ attrs["llm.token_count.prompt_details.cache_read"] = span.token_usage.cached_tokens
132
+ if span.token_usage.reasoning_tokens is not None:
133
+ attrs["llm.token_count.completion_details.reasoning"] = span.token_usage.reasoning_tokens
134
+
135
+
136
+ def _populate_cost_attrs(span: Span, attrs: dict[str, Any]) -> None:
137
+ if span.cost is None:
138
+ return
139
+ attrs["llm.cost.prompt"] = span.cost.input_cost_usd
140
+ attrs["llm.cost.completion"] = span.cost.output_cost_usd
141
+ attrs["llm.cost.total"] = span.cost.total_cost_usd
142
+ attrs["llm.cost.prompt_details.input"] = span.cost.input_cost_usd
143
+ attrs["llm.cost.completion_details.output"] = span.cost.output_cost_usd
144
+ if span.cost.cached_discount_usd:
145
+ attrs["llm.cost.prompt_details.cache_read"] = span.cost.cached_discount_usd
146
+ if span.cost.reasoning_cost_usd:
147
+ attrs["llm.cost.completion_details.reasoning"] = span.cost.reasoning_cost_usd
148
+
149
+
150
+ def _first_string(attrs: dict[str, Any], *keys: str) -> str | None:
151
+ for key in keys:
152
+ value = attrs.get(key)
153
+ if value is None:
154
+ continue
155
+ if isinstance(value, str):
156
+ return value
157
+ return json.dumps(value, sort_keys=True, default=str)
158
+ return None