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,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
|
+
)
|