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,331 @@
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(
81
+ f"{param_name} must be a valid http:// or https:// URL; got {url!r}"
82
+ )
83
+ if not allow_private_addresses:
84
+ host = parsed.hostname or ""
85
+ if _is_private_ip_literal(host):
86
+ raise ValueError(
87
+ f"{param_name} resolves to a private/loopback/link-local IP address "
88
+ f"({host!r}). Set allow_private_addresses=True to permit this."
89
+ )
90
+ # DNS-based SSRF check — best-effort; DNS failure is non-fatal.
91
+ if host and not _is_private_ip_literal(host):
92
+ try:
93
+ resolved = socket.gethostbyname(host)
94
+ addr = ipaddress.ip_address(resolved)
95
+ if addr.is_private or addr.is_loopback or addr.is_link_local:
96
+ raise ValueError(
97
+ f"{param_name} hostname {host!r} resolves to a private/loopback/"
98
+ f"link-local address ({resolved}). "
99
+ "Set allow_private_addresses=True to permit this."
100
+ )
101
+ except OSError: # DNS failure — allow through
102
+ pass
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Main exporter
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ class GrafanaLokiExporter:
111
+ """Async exporter that ships SpanForge events to Grafana Loki.
112
+
113
+ Args:
114
+ url: Loki base URL (e.g. ``"http://localhost:3100"``).
115
+ labels: Global stream labels applied to every entry.
116
+ timeout: Per-request timeout in seconds (default 10.0).
117
+ tenant_id: When set, included in ``X-Scope-OrgID`` header.
118
+ include_envelope_labels: Whether to add ``event_type`` and ``org_id``
119
+ from the event envelope to the stream labels
120
+ (default ``True``).
121
+
122
+ Raises:
123
+ ValueError: If *url* is not a valid HTTP/HTTPS URL or *timeout* is not
124
+ positive.
125
+ """
126
+
127
+ def __init__( # noqa: PLR0913
128
+ self,
129
+ url: str,
130
+ *,
131
+ labels: dict[str, str] | None = None,
132
+ timeout: float = 10.0,
133
+ tenant_id: str | None = None,
134
+ include_envelope_labels: bool = True,
135
+ allow_private_addresses: bool = False,
136
+ ) -> None:
137
+ if timeout <= 0:
138
+ raise ValueError("timeout must be positive")
139
+ _validate_http_url(url, "url", allow_private_addresses=allow_private_addresses)
140
+
141
+ self._base_url = url.rstrip("/")
142
+ self._global_labels: dict[str, str] = dict(labels or {})
143
+ self._timeout = timeout
144
+ self._tenant_id: str | None = tenant_id
145
+ self._include_envelope_labels = include_envelope_labels
146
+
147
+ # ------------------------------------------------------------------
148
+ # Public conversion API
149
+ # ------------------------------------------------------------------
150
+
151
+ def event_to_loki_entry(self, event: Event) -> dict[str, Any]:
152
+ """Convert a SpanForge :class:`~spanforge.event.Event` to a Loki log entry dict.
153
+
154
+ The returned dict has shape::
155
+
156
+ {
157
+ "stream": {"key": "value", ...},
158
+ "values": [["<nanoseconds>", "<json payload>"]],
159
+ }
160
+
161
+ Args:
162
+ event: The event to convert.
163
+
164
+ Returns:
165
+ A dict ready to be included in a Loki push request.
166
+ """
167
+ # Build stream labels
168
+ stream: dict[str, str] = {}
169
+
170
+ if self._include_envelope_labels:
171
+ # Replace dots with underscores — Loki label values are Prometheus labels
172
+ event_type_label = str(event.event_type).replace(".", "_")
173
+ stream["event_type"] = event_type_label
174
+ if event.org_id:
175
+ stream["org_id"] = event.org_id
176
+
177
+ # User-supplied global labels come last (may override envelope labels)
178
+ stream.update(self._global_labels)
179
+
180
+ # Build the log line (JSON)
181
+ try:
182
+ line = event.to_json()
183
+ except Exception: # NOSONAR
184
+ line = json.dumps(
185
+ {
186
+ "event_id": str(getattr(event, "event_id", "")),
187
+ "event_type": str(event.event_type),
188
+ "timestamp": event.timestamp,
189
+ }
190
+ )
191
+
192
+ # Timestamp in nanoseconds as string
193
+ ns_str = str(self._iso_to_ns(event.timestamp))
194
+
195
+ return {
196
+ "stream": stream,
197
+ "values": [[ns_str, line]],
198
+ }
199
+
200
+ @staticmethod
201
+ def _iso_to_ns(ts: str) -> int:
202
+ """Convert an ISO-8601 timestamp string to nanoseconds since Unix epoch.
203
+
204
+ Args:
205
+ ts: ISO-8601 datetime string (e.g. ``"2024-01-15T12:00:00.000000Z"``).
206
+
207
+ Returns:
208
+ Integer nanoseconds since the Unix epoch.
209
+ """
210
+ try:
211
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
212
+ if dt.tzinfo is None:
213
+ dt = dt.replace(tzinfo=timezone.utc)
214
+ return int(dt.timestamp() * 1_000_000_000)
215
+ except ValueError as exc:
216
+ raise ExportError(
217
+ "grafana_loki",
218
+ f"cannot parse event timestamp {ts!r}: {exc}",
219
+ ) from exc
220
+
221
+ # ------------------------------------------------------------------
222
+ # Async export API
223
+ # ------------------------------------------------------------------
224
+
225
+ async def export(self, event: Event) -> None:
226
+ """Export a single event to Grafana Loki.
227
+
228
+ Args:
229
+ event: The event to export.
230
+
231
+ Raises:
232
+ ExportError: On HTTP or network errors.
233
+ """
234
+ await self.export_batch([event])
235
+
236
+ async def export_batch(self, events: Sequence[Event]) -> int:
237
+ """Export multiple events to Grafana Loki.
238
+
239
+ Events that share identical stream labels are grouped into the same
240
+ Loki stream to reduce push requests.
241
+
242
+ Args:
243
+ events: Sequence of events to deliver.
244
+
245
+ Returns:
246
+ Number of events successfully submitted.
247
+
248
+ Raises:
249
+ ExportError: On HTTP or network errors.
250
+ """
251
+ if not events:
252
+ return 0
253
+
254
+ # Group by frozenset of stream label items
255
+ groups: dict[Any, tuple[dict[str, str], list[list[str]]]] = {}
256
+ for event in events:
257
+ entry = self.event_to_loki_entry(event)
258
+ stream = entry["stream"]
259
+ key = frozenset(stream.items())
260
+ if key not in groups:
261
+ groups[key] = (stream, [])
262
+ groups[key][1].extend(entry["values"])
263
+
264
+ streams: list[dict[str, Any]] = [
265
+ {"stream": stream_labels, "values": values}
266
+ for (stream_labels, values) in groups.values()
267
+ ]
268
+
269
+ payload = json.dumps({"streams": streams}).encode("utf-8")
270
+ await self._push(payload)
271
+ return len(events)
272
+
273
+ # ------------------------------------------------------------------
274
+ # Internal HTTP helpers
275
+ # ------------------------------------------------------------------
276
+
277
+ async def _push(self, payload: bytes) -> None:
278
+ """Push a serialised Loki request body to the ingest endpoint.
279
+
280
+ Args:
281
+ payload: JSON-encoded bytes to POST.
282
+
283
+ Raises:
284
+ ExportError: On HTTP or network failure.
285
+ """
286
+ await asyncio.get_event_loop().run_in_executor(
287
+ None, lambda: self._do_push(payload)
288
+ )
289
+
290
+ def _do_push(self, body: bytes) -> None:
291
+ """Perform a synchronous HTTP POST to ``/loki/api/v1/push`` (called from executor).
292
+
293
+ Args:
294
+ body: Request body bytes.
295
+
296
+ Raises:
297
+ ExportError: On HTTP or network failure.
298
+ EgressViolationError: If the endpoint is blocked by egress policy.
299
+ """
300
+ from spanforge.egress import check_egress # noqa: PLC0415
301
+
302
+ url = f"{self._base_url}/loki/api/v1/push"
303
+ check_egress(url, backend="grafana-loki")
304
+ headers: dict[str, str] = {
305
+ "Content-Type": "application/json",
306
+ }
307
+ if self._tenant_id:
308
+ headers["X-Scope-OrgID"] = self._tenant_id
309
+
310
+ req = urllib.request.Request(url=url, data=body, headers=headers, method="POST") # noqa: S310 # NOSONAR
311
+ try:
312
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310 # NOSONAR
313
+ resp.read()
314
+ except urllib.error.HTTPError as exc:
315
+ raise ExportError(
316
+ "grafana-loki", f"HTTP {exc.code} from {url}: {exc.reason}"
317
+ ) from exc
318
+ except OSError as exc:
319
+ raise ExportError(
320
+ "grafana-loki", f"network error posting to {url}: {exc}"
321
+ ) from exc
322
+
323
+ # ------------------------------------------------------------------
324
+ # dunder
325
+ # ------------------------------------------------------------------
326
+
327
+ def __repr__(self) -> str:
328
+ return (
329
+ f"GrafanaLokiExporter(url={self._base_url!r}, "
330
+ f"tenant_id={self._tenant_id!r})"
331
+ )
@@ -0,0 +1,198 @@
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( # noqa: SIM115
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 (
196
+ f"JSONLExporter(path={self._path_str!r}, "
197
+ f"mode={self._mode!r})"
198
+ )