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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- 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 {}
|