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,233 @@
1
+ """spanforge.export.otlp_bridge — Lightweight Span → OTLP dict translation.
2
+
3
+ Translates :class:`~spanforge._span.Span` instances into the OTLP ``ReadableSpan``
4
+ dict format **without** requiring the ``opentelemetry-sdk`` package. The produced
5
+ dicts are wire-compatible with the OTLP/JSON specification and can be embedded
6
+ directly into ``resourceSpans[].scopeSpans[].spans`` payloads.
7
+
8
+ This complements :mod:`spanforge.export.otel_bridge` (which bridges to the OTel
9
+ SDK's ``TracerProvider``) by providing a zero-dependency serialisation path for
10
+ testing, custom exporters, and SDK-less environments.
11
+
12
+ Usage::
13
+
14
+ from spanforge.export.otlp_bridge import span_to_otlp_dict, SpanOTLPBridge
15
+
16
+ # Single span → OTLP dict (no OTel SDK required)
17
+ otlp = span_to_otlp_dict(my_span)
18
+ print(otlp["name"], otlp["traceId"], otlp["spanId"])
19
+
20
+ # Full OTLP resource-spans envelope
21
+ bridge = SpanOTLPBridge(service_name="my-agent")
22
+ payload = bridge.to_resource_spans([span1, span2])
23
+ # → {"resourceSpans": [...]}
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ if TYPE_CHECKING:
31
+ from spanforge._span import Span
32
+
33
+ __all__ = ["SpanOTLPBridge", "span_to_otlp_dict"]
34
+
35
+ # OTLP SpanKind integer constants (opentelemetry.proto.trace.v1.Span.SpanKind).
36
+ _OTLP_SPAN_KIND_UNSPECIFIED = 0
37
+ _OTLP_SPAN_KIND_INTERNAL = 1
38
+ _OTLP_SPAN_KIND_SERVER = 2
39
+ _OTLP_SPAN_KIND_CLIENT = 3
40
+ _OTLP_SPAN_KIND_PRODUCER = 4
41
+ _OTLP_SPAN_KIND_CONSUMER = 5
42
+
43
+ # OTLP Status codes (opentelemetry.proto.trace.v1.Status.StatusCode).
44
+ _OTLP_STATUS_UNSET = 0
45
+ _OTLP_STATUS_OK = 1
46
+ _OTLP_STATUS_ERROR = 2
47
+
48
+ _SCOPE_NAME = "spanforge"
49
+ _SCOPE_VERSION = "2.0.0"
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Attribute helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _string_attr(key: str, value: str) -> dict[str, Any]:
58
+ return {"key": key, "value": {"stringValue": value}}
59
+
60
+
61
+ def _int_attr(key: str, value: int) -> dict[str, Any]:
62
+ return {"key": key, "value": {"intValue": str(value)}}
63
+
64
+
65
+ def _bool_attr(key: str, value: bool) -> dict[str, Any]:
66
+ return {"key": key, "value": {"boolValue": value}}
67
+
68
+
69
+ def _double_attr(key: str, value: float) -> dict[str, Any]:
70
+ return {"key": key, "value": {"doubleValue": value}}
71
+
72
+
73
+ def _to_otlp_attr(key: str, value: Any) -> dict[str, Any]:
74
+ """Convert a single key-value pair to an OTLP-format attribute dict."""
75
+ if isinstance(value, bool):
76
+ return _bool_attr(key, value)
77
+ if isinstance(value, int):
78
+ return _int_attr(key, value)
79
+ if isinstance(value, float):
80
+ return _double_attr(key, value)
81
+ return _string_attr(key, str(value))
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # span_to_otlp_dict
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def span_to_otlp_dict(span: Span) -> dict[str, Any]:
90
+ """Translate a :class:`~spanforge._span.Span` to an OTLP span dict.
91
+
92
+ The returned dict conforms to the OTLP/JSON ``Span`` protobuf shape
93
+ (``opentelemetry.proto.trace.v1.Span``). All nanosecond timestamps are
94
+ serialised as **strings** per the OTLP/JSON spec.
95
+
96
+ Args:
97
+ span: A :class:`~spanforge._span.Span` instance (open or closed).
98
+ When ``end_ns`` is ``None`` the current time is substituted.
99
+
100
+ Returns:
101
+ OTLP-format ``dict`` ready to embed in a ``resourceSpans`` payload.
102
+ """
103
+ import time
104
+
105
+ start_ns = span.start_ns
106
+ end_ns = span.end_ns if span.end_ns is not None else time.time_ns()
107
+
108
+ # Build attributes list following gen_ai.* semantic conventions.
109
+ attrs: list[dict[str, Any]] = []
110
+ if span.model:
111
+ attrs.append(_string_attr("gen_ai.request.model", span.model))
112
+ if span.operation:
113
+ attrs.append(_string_attr("gen_ai.operation.name", str(span.operation)))
114
+ if span.temperature is not None:
115
+ attrs.append(_double_attr("gen_ai.request.temperature", span.temperature))
116
+ if span.top_p is not None:
117
+ attrs.append(_double_attr("gen_ai.request.top_p", span.top_p))
118
+ if span.max_tokens is not None:
119
+ attrs.append(_int_attr("gen_ai.request.max_tokens", span.max_tokens))
120
+ if span.token_usage is not None:
121
+ tu = span.token_usage
122
+ if tu.input_tokens is not None:
123
+ attrs.append(_int_attr("gen_ai.usage.input_tokens", tu.input_tokens))
124
+ if tu.output_tokens is not None:
125
+ attrs.append(_int_attr("gen_ai.usage.output_tokens", tu.output_tokens))
126
+ if tu.total_tokens is not None:
127
+ attrs.append(_int_attr("gen_ai.usage.total_tokens", tu.total_tokens))
128
+ if span.error:
129
+ attrs.append(_string_attr("exception.message", span.error))
130
+ if span.error_type:
131
+ attrs.append(_string_attr("exception.type", span.error_type))
132
+ if span.error_category:
133
+ attrs.append(_string_attr("spanforge.error.category", span.error_category))
134
+ for k, v in (span.attributes or {}).items():
135
+ attrs.append(_to_otlp_attr(str(k), v))
136
+
137
+ # Status code mapping.
138
+ status_code = _OTLP_STATUS_ERROR if span.status == "error" else _OTLP_STATUS_OK
139
+ otlp_status: dict[str, Any] = {"code": status_code}
140
+ if span.error:
141
+ otlp_status["message"] = span.error
142
+
143
+ result: dict[str, Any] = {
144
+ "traceId": span.trace_id,
145
+ "spanId": span.span_id,
146
+ "name": span.name,
147
+ "kind": _OTLP_SPAN_KIND_CLIENT,
148
+ "startTimeUnixNano": str(start_ns),
149
+ "endTimeUnixNano": str(end_ns),
150
+ "attributes": attrs,
151
+ "status": otlp_status,
152
+ "events": [],
153
+ "links": [],
154
+ }
155
+
156
+ if span.parent_span_id:
157
+ result["parentSpanId"] = span.parent_span_id
158
+
159
+ # Translate SpanEvent list to OTLP events.
160
+ for ev in span.events or []:
161
+ ev_attrs = [_to_otlp_attr(str(k), v) for k, v in (ev.metadata or {}).items()]
162
+ result["events"].append(
163
+ {
164
+ "name": ev.name,
165
+ "timeUnixNano": str(start_ns), # SpanEvent has no own timestamp; use span start
166
+ "attributes": ev_attrs,
167
+ }
168
+ )
169
+
170
+ return result
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # SpanOTLPBridge
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ class SpanOTLPBridge:
179
+ """Assembles OTLP ``resourceSpans`` payloads from :class:`~spanforge._span.Span` lists.
180
+
181
+ Usage::
182
+
183
+ bridge = SpanOTLPBridge(service_name="my-agent", service_version="1.0.0")
184
+ payload = bridge.to_resource_spans([span1, span2])
185
+ # → {"resourceSpans": [...]}
186
+
187
+ Args:
188
+ service_name: Value for the ``service.name`` resource attribute.
189
+ service_version: Optional value for the ``service.version`` resource attribute.
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ service_name: str = "spanforge",
195
+ service_version: str | None = None,
196
+ ) -> None:
197
+ self.service_name = service_name
198
+ self.service_version = service_version
199
+
200
+ def to_resource_spans(self, spans: list[Span]) -> dict[str, Any]:
201
+ """Build a complete OTLP/JSON ``resourceSpans`` envelope from *spans*.
202
+
203
+ Args:
204
+ spans: List of :class:`~spanforge._span.Span` objects to serialise.
205
+
206
+ Returns:
207
+ ``{"resourceSpans": [...]}`` dict for JSON serialisation or forwarding
208
+ to an OTLP collector.
209
+ """
210
+ resource_attrs: list[dict[str, Any]] = [
211
+ _string_attr("service.name", self.service_name),
212
+ ]
213
+ if self.service_version:
214
+ resource_attrs.append(_string_attr("service.version", self.service_version))
215
+
216
+ otlp_spans = [span_to_otlp_dict(s) for s in spans]
217
+
218
+ return {
219
+ "resourceSpans": [
220
+ {
221
+ "resource": {"attributes": resource_attrs},
222
+ "scopeSpans": [
223
+ {
224
+ "scope": {
225
+ "name": _SCOPE_NAME,
226
+ "version": _SCOPE_VERSION,
227
+ },
228
+ "spans": otlp_spans,
229
+ }
230
+ ],
231
+ }
232
+ ]
233
+ }
@@ -0,0 +1,282 @@
1
+ """spanforge.export.redis_backend — Redis-backed event cache and exporter.
2
+
3
+ Stores serialised spanforge events in a Redis stream (``XADD``) or a capped
4
+ list, making exported events available to multiple consumers and providing a
5
+ persistent replay buffer.
6
+
7
+ **Optional dependency**: requires ``redis >= 4.0``. Install via::
8
+
9
+ pip install "spanforge[redis]"
10
+
11
+ Usage::
12
+
13
+ import asyncio
14
+ import spanforge
15
+ from spanforge.export.redis_backend import RedisExporter
16
+
17
+ async def main():
18
+ exporter = RedisExporter(url="redis://localhost:6379", stream_key="spanforge:events")
19
+ spanforge.configure(exporter="redis", endpoint="redis://localhost:6379")
20
+
21
+ async with exporter:
22
+ with spanforge.span("my-llm-call") as span:
23
+ span.set_model(model="gpt-4o", system="openai")
24
+ span.set_status("ok")
25
+
26
+ asyncio.run(main())
27
+
28
+ Reading events back::
29
+
30
+ from spanforge.export.redis_backend import RedisEventReader
31
+ reader = RedisEventReader(url="redis://localhost:6379", stream_key="spanforge:events")
32
+ async for event_dict in reader.read(count=100):
33
+ print(event_dict)
34
+
35
+ Environment variables::
36
+
37
+ SPANFORGE_REDIS_URL Redis connection URL (default: redis://localhost:6379)
38
+ SPANFORGE_REDIS_STREAM_KEY Stream key name (default: spanforge:events)
39
+ SPANFORGE_REDIS_MAX_LEN Max stream length / MAXLEN trim (default: 100000)
40
+ SPANFORGE_REDIS_TTL_SECONDS Per-entry TTL; 0 = no TTL (default: 0)
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import logging
47
+ import os
48
+ from typing import TYPE_CHECKING, Any
49
+
50
+ if TYPE_CHECKING:
51
+ from collections.abc import AsyncIterator
52
+
53
+ from spanforge.event import Event
54
+
55
+ __all__ = ["RedisEventReader", "RedisExporter"]
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ _DEFAULT_URL = "redis://localhost:6379"
60
+ _DEFAULT_STREAM_KEY = "spanforge:events"
61
+ _DEFAULT_MAX_LEN = 100_000
62
+
63
+
64
+ def _require_redis() -> Any:
65
+ """Import and return the redis.asyncio module, raising ImportError with hint if absent."""
66
+ try:
67
+ import redis.asyncio as aioredis
68
+ except ImportError as exc:
69
+ raise ImportError(
70
+ "The Redis exporter requires the 'redis' package. "
71
+ 'Install it with: pip install "spanforge[redis]"'
72
+ ) from exc
73
+ else:
74
+ return aioredis
75
+
76
+
77
+ class RedisExporter:
78
+ """Async exporter that writes spanforge events into a Redis Stream.
79
+
80
+ Uses Redis Streams (``XADD``) with approximate ``MAXLEN`` trimming to
81
+ provide a durable, multi-consumer event buffer.
82
+
83
+ Args:
84
+ url: Redis connection URL (``redis://``, ``rediss://``, or
85
+ ``unix://`` socket path).
86
+ stream_key: Redis stream key name.
87
+ max_len: Approximate maximum number of entries retained in the stream.
88
+ Older entries are trimmed automatically.
89
+ ttl_seconds: When > 0, the Redis key TTL is refreshed on every write
90
+ (sliding expiry). ``0`` disables TTL management.
91
+ decode_responses: Whether to decode Redis responses to Python strings
92
+ (default: False — bytes are returned for payloads).
93
+
94
+ Thread / coroutine safety:
95
+ The underlying ``redis.asyncio`` client is coroutine-safe. Concurrent
96
+ calls to :meth:`export` are safe without external locking.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ url: str = _DEFAULT_URL,
102
+ stream_key: str = _DEFAULT_STREAM_KEY,
103
+ max_len: int = _DEFAULT_MAX_LEN,
104
+ ttl_seconds: int = 0,
105
+ *,
106
+ decode_responses: bool = False,
107
+ ) -> None:
108
+ self._url = url or os.environ.get("SPANFORGE_REDIS_URL", _DEFAULT_URL)
109
+ self._stream_key = stream_key or os.environ.get(
110
+ "SPANFORGE_REDIS_STREAM_KEY", _DEFAULT_STREAM_KEY
111
+ )
112
+ max_len_env = int(os.environ.get("SPANFORGE_REDIS_MAX_LEN", str(_DEFAULT_MAX_LEN)))
113
+ self._max_len = max_len if max_len != _DEFAULT_MAX_LEN else max_len_env
114
+ ttl_env = int(os.environ.get("SPANFORGE_REDIS_TTL_SECONDS", "0"))
115
+ self._ttl = ttl_seconds if ttl_seconds != 0 else ttl_env
116
+ self._decode = decode_responses
117
+ self._client: object | None = None
118
+
119
+ # ------------------------------------------------------------------
120
+ # Async context manager
121
+ # ------------------------------------------------------------------
122
+
123
+ async def __aenter__(self) -> RedisExporter:
124
+ await self._connect()
125
+ return self
126
+
127
+ async def __aexit__(self, *_: object) -> None:
128
+ await self.close()
129
+
130
+ # ------------------------------------------------------------------
131
+ # Internal
132
+ # ------------------------------------------------------------------
133
+
134
+ async def _connect(self) -> None:
135
+ if self._client is not None:
136
+ return
137
+ aioredis = _require_redis()
138
+ self._client = aioredis.from_url(self._url, decode_responses=self._decode)
139
+
140
+ # ------------------------------------------------------------------
141
+ # Public interface
142
+ # ------------------------------------------------------------------
143
+
144
+ async def export(self, event: Event) -> None:
145
+ """Serialize *event* and write it to the Redis stream.
146
+
147
+ Args:
148
+ event: spanforge :class:`~spanforge.event.Event` instance.
149
+
150
+ Raises:
151
+ ImportError: If the ``redis`` package is not installed.
152
+ redis.RedisError: On connection or write failure.
153
+ """
154
+ if self._client is None:
155
+ await self._connect()
156
+
157
+ payload = json.dumps(event.to_dict(), separators=(",", ":"), default=str)
158
+ fields = {
159
+ b"data" if not self._decode else "data": (
160
+ payload.encode() if not self._decode else payload
161
+ ),
162
+ b"event_id" if not self._decode else "event_id": (
163
+ event.event_id.encode() if not self._decode else event.event_id
164
+ ),
165
+ b"event_type" if not self._decode else "event_type": (
166
+ event.event_type.encode() if not self._decode else event.event_type
167
+ ),
168
+ }
169
+ assert self._client is not None # nosec B101
170
+ await self._client.xadd( # type: ignore[attr-defined]
171
+ self._stream_key,
172
+ fields,
173
+ maxlen=self._max_len,
174
+ approximate=True,
175
+ )
176
+ if self._ttl > 0:
177
+ await self._client.expire(self._stream_key, self._ttl) # type: ignore[attr-defined]
178
+
179
+ async def export_batch(self, events: list[Event]) -> None:
180
+ """Write multiple events in a single pipeline round-trip."""
181
+ if not events:
182
+ return
183
+ if self._client is None:
184
+ await self._connect()
185
+
186
+ assert self._client is not None # nosec B101
187
+ pipe = self._client.pipeline(transaction=False) # type: ignore[attr-defined]
188
+ for event in events:
189
+ payload = json.dumps(event.to_dict(), separators=(",", ":"), default=str)
190
+ fields = {
191
+ b"data" if not self._decode else "data": (
192
+ payload.encode() if not self._decode else payload
193
+ ),
194
+ b"event_id" if not self._decode else "event_id": (
195
+ event.event_id.encode() if not self._decode else event.event_id
196
+ ),
197
+ }
198
+ pipe.xadd(self._stream_key, fields, maxlen=self._max_len, approximate=True)
199
+ if self._ttl > 0:
200
+ pipe.expire(self._stream_key, self._ttl)
201
+ await pipe.execute()
202
+
203
+ async def close(self) -> None:
204
+ """Close the underlying Redis connection."""
205
+ if self._client is not None:
206
+ try:
207
+ await self._client.aclose() # type: ignore[attr-defined]
208
+ except Exception as _err:
209
+ logger.debug("Redis close error: %s", _err)
210
+ finally:
211
+ self._client = None
212
+
213
+ async def flush(self) -> None:
214
+ """No-op — Redis writes are immediately durable."""
215
+
216
+
217
+ class RedisEventReader:
218
+ """Read spanforge events back from a Redis Stream.
219
+
220
+ Args:
221
+ url: Redis connection URL.
222
+ stream_key: Redis stream key name.
223
+
224
+ Example::
225
+
226
+ reader = RedisEventReader(url="redis://localhost:6379")
227
+ async for raw in reader.read(count=50, last_id="0"):
228
+ print(raw)
229
+ """
230
+
231
+ def __init__(
232
+ self,
233
+ url: str = _DEFAULT_URL,
234
+ stream_key: str = _DEFAULT_STREAM_KEY,
235
+ ) -> None:
236
+ self._url = url
237
+ self._stream_key = stream_key
238
+ self._client: object | None = None
239
+
240
+ async def __aenter__(self) -> RedisEventReader:
241
+ aioredis = _require_redis()
242
+ self._client = aioredis.from_url(self._url, decode_responses=True)
243
+ return self
244
+
245
+ async def __aexit__(self, *_: object) -> None:
246
+ if self._client is not None:
247
+ await self._client.aclose() # type: ignore[attr-defined]
248
+
249
+ async def read(
250
+ self,
251
+ count: int = 100,
252
+ last_id: str = "0",
253
+ ) -> AsyncIterator[dict[str, Any]]:
254
+ """Yield event dicts from the stream starting after *last_id*.
255
+
256
+ Args:
257
+ count: Maximum number of entries to fetch per call.
258
+ last_id: Stream entry ID to start reading from (exclusive).
259
+ ``"0"`` reads from the beginning.
260
+ ``"$"`` reads only new entries.
261
+
262
+ Yields:
263
+ Raw event dicts (deserialised from the ``data`` field).
264
+ """
265
+ if self._client is None:
266
+ raise RuntimeError("Use 'async with RedisEventReader(...) as reader:' first")
267
+
268
+ entries = await self._client.xread( # type: ignore[attr-defined]
269
+ {self._stream_key: last_id}, count=count
270
+ )
271
+ if not entries:
272
+ return
273
+ for _stream_name, records in entries:
274
+ for _entry_id, fields in records:
275
+ raw = fields.get("data") or fields.get(b"data", b"{}")
276
+ if isinstance(raw, bytes):
277
+ raw = raw.decode()
278
+ try:
279
+ yield json.loads(raw)
280
+ except json.JSONDecodeError:
281
+ logger.warning("RedisEventReader: could not deserialise entry")
282
+ continue
@@ -0,0 +1,98 @@
1
+ """SIEM-friendly schema normalization for SpanForge events.
2
+
3
+ Provides one canonical event mapping that downstream SIEM exporters can reuse
4
+ so Splunk, syslog/CEF, and JSON-based audit sinks see the same core fields.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from spanforge.event import Event
13
+
14
+ __all__ = ["event_to_siem_record", "severity_from_event"]
15
+
16
+ _SEVERITY_MAP: dict[str, int] = {
17
+ "alert": 1,
18
+ "error": 3,
19
+ "warn": 4,
20
+ "warning": 4,
21
+ "info": 6,
22
+ "debug": 7,
23
+ "trace": 7,
24
+ }
25
+
26
+
27
+ def severity_from_event(event: Event) -> int:
28
+ """Map the event_type prefix to a syslog-style severity."""
29
+ prefix = event.event_type.split(".")[0].lower()
30
+ return _SEVERITY_MAP.get(prefix, 6)
31
+
32
+
33
+ def event_to_siem_record(event: Event) -> dict[str, Any]:
34
+ """Return a normalized SIEM record for one SpanForge event."""
35
+ payload = _safe_payload(getattr(event, "payload", {}))
36
+ tags = _safe_tags(getattr(event, "tags", None))
37
+ record = {
38
+ "event_id": getattr(event, "event_id", None),
39
+ "event_type": getattr(event, "event_type", None),
40
+ "schema_version": getattr(event, "schema_version", None),
41
+ "timestamp": getattr(event, "timestamp", None),
42
+ "source": getattr(event, "source", None),
43
+ "trace_id": getattr(event, "trace_id", None),
44
+ "span_id": getattr(event, "span_id", None),
45
+ "parent_span_id": getattr(event, "parent_span_id", None),
46
+ "org_id": getattr(event, "org_id", None),
47
+ "team_id": getattr(event, "team_id", None),
48
+ "actor_id": getattr(event, "actor_id", None),
49
+ "session_id": getattr(event, "session_id", None),
50
+ "siem": {
51
+ "schema": "spanforge.event.v1",
52
+ "category": _category_for_event_type(str(getattr(event, "event_type", ""))),
53
+ "severity": severity_from_event(event),
54
+ },
55
+ "payload": payload,
56
+ "tags": tags,
57
+ }
58
+ normalized = json.loads(json.dumps(record, sort_keys=True, default=str))
59
+ return {key: value for key, value in normalized.items() if value not in (None, {})}
60
+
61
+
62
+ def _category_for_event_type(event_type: str) -> str:
63
+ if ".policy." in event_type:
64
+ return "policy"
65
+ if ".audit." in event_type:
66
+ return "audit"
67
+ if ".scope." in event_type:
68
+ return "scope"
69
+ if ".rbac." in event_type:
70
+ return "rbac"
71
+ if ".rag." in event_type or ".ground" in event_type:
72
+ return "grounding"
73
+ if ".lineage." in event_type:
74
+ return "lineage"
75
+ if ".tool" in event_type:
76
+ return "tool"
77
+ if ".trace." in event_type or event_type.startswith("trace."):
78
+ return "trace"
79
+ return "application"
80
+
81
+
82
+ def _safe_payload(payload: Any) -> dict[str, Any]:
83
+ if isinstance(payload, dict):
84
+ return payload
85
+ try:
86
+ return dict(payload)
87
+ except Exception:
88
+ return {"value": payload}
89
+
90
+
91
+ def _safe_tags(tags: Any) -> dict[str, Any]:
92
+ if tags is None:
93
+ return {}
94
+ try:
95
+ value = tags.to_dict() if hasattr(tags, "to_dict") else dict(tags)
96
+ return value if isinstance(value, dict) else {}
97
+ except Exception:
98
+ return {}