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,231 @@
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 Any, TYPE_CHECKING
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 # noqa: F841
37
+ _OTLP_SPAN_KIND_INTERNAL = 1
38
+ _OTLP_SPAN_KIND_SERVER = 2 # noqa: F841
39
+ _OTLP_SPAN_KIND_CLIENT = 3
40
+ _OTLP_SPAN_KIND_PRODUCER = 4 # noqa: F841
41
+ _OTLP_SPAN_KIND_CONSUMER = 5 # noqa: F841
42
+
43
+ # OTLP Status codes (opentelemetry.proto.trace.v1.Status.StatusCode).
44
+ _OTLP_STATUS_UNSET = 0 # noqa: F841
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]: # noqa: ANN401
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 # noqa: PLC0415
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
+ "name": ev.name,
164
+ "timeUnixNano": str(start_ns), # SpanEvent has no own timestamp; use span start
165
+ "attributes": ev_attrs,
166
+ })
167
+
168
+ return result
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # SpanOTLPBridge
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ class SpanOTLPBridge:
177
+ """Assembles OTLP ``resourceSpans`` payloads from :class:`~spanforge._span.Span` lists.
178
+
179
+ Usage::
180
+
181
+ bridge = SpanOTLPBridge(service_name="my-agent", service_version="1.0.0")
182
+ payload = bridge.to_resource_spans([span1, span2])
183
+ # → {"resourceSpans": [...]}
184
+
185
+ Args:
186
+ service_name: Value for the ``service.name`` resource attribute.
187
+ service_version: Optional value for the ``service.version`` resource attribute.
188
+ """
189
+
190
+ def __init__(
191
+ self,
192
+ service_name: str = "spanforge",
193
+ service_version: str | None = None,
194
+ ) -> None:
195
+ self.service_name = service_name
196
+ self.service_version = service_version
197
+
198
+ def to_resource_spans(self, spans: list["Span"]) -> dict[str, Any]:
199
+ """Build a complete OTLP/JSON ``resourceSpans`` envelope from *spans*.
200
+
201
+ Args:
202
+ spans: List of :class:`~spanforge._span.Span` objects to serialise.
203
+
204
+ Returns:
205
+ ``{"resourceSpans": [...]}`` dict for JSON serialisation or forwarding
206
+ to an OTLP collector.
207
+ """
208
+ resource_attrs: list[dict[str, Any]] = [
209
+ _string_attr("service.name", self.service_name),
210
+ ]
211
+ if self.service_version:
212
+ resource_attrs.append(_string_attr("service.version", self.service_version))
213
+
214
+ otlp_spans = [span_to_otlp_dict(s) for s in spans]
215
+
216
+ return {
217
+ "resourceSpans": [
218
+ {
219
+ "resource": {"attributes": resource_attrs},
220
+ "scopeSpans": [
221
+ {
222
+ "scope": {
223
+ "name": _SCOPE_NAME,
224
+ "version": _SCOPE_VERSION,
225
+ },
226
+ "spans": otlp_spans,
227
+ }
228
+ ],
229
+ }
230
+ ]
231
+ }
@@ -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, AsyncIterator, Optional
49
+
50
+ if TYPE_CHECKING:
51
+ from spanforge.event import Event
52
+
53
+ __all__ = ["RedisExporter", "RedisEventReader"]
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ _DEFAULT_URL = "redis://localhost:6379"
58
+ _DEFAULT_STREAM_KEY = "spanforge:events"
59
+ _DEFAULT_MAX_LEN = 100_000
60
+
61
+
62
+ def _require_redis():
63
+ """Import and return the redis.asyncio module, raising ImportError with hint if absent."""
64
+ try:
65
+ import redis.asyncio as aioredis # type: ignore[import-untyped]
66
+
67
+ return 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
+
74
+
75
+ class RedisExporter:
76
+ """Async exporter that writes spanforge events into a Redis Stream.
77
+
78
+ Uses Redis Streams (``XADD``) with approximate ``MAXLEN`` trimming to
79
+ provide a durable, multi-consumer event buffer.
80
+
81
+ Args:
82
+ url: Redis connection URL (``redis://``, ``rediss://``, or
83
+ ``unix://`` socket path).
84
+ stream_key: Redis stream key name.
85
+ max_len: Approximate maximum number of entries retained in the stream.
86
+ Older entries are trimmed automatically.
87
+ ttl_seconds: When > 0, the Redis key TTL is refreshed on every write
88
+ (sliding expiry). ``0`` disables TTL management.
89
+ decode_responses: Whether to decode Redis responses to Python strings
90
+ (default: False — bytes are returned for payloads).
91
+
92
+ Thread / coroutine safety:
93
+ The underlying ``redis.asyncio`` client is coroutine-safe. Concurrent
94
+ calls to :meth:`export` are safe without external locking.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ url: str = _DEFAULT_URL,
100
+ stream_key: str = _DEFAULT_STREAM_KEY,
101
+ max_len: int = _DEFAULT_MAX_LEN,
102
+ ttl_seconds: int = 0,
103
+ *,
104
+ decode_responses: bool = False,
105
+ ) -> None:
106
+ self._url = url or os.environ.get("SPANFORGE_REDIS_URL", _DEFAULT_URL)
107
+ self._stream_key = stream_key or os.environ.get(
108
+ "SPANFORGE_REDIS_STREAM_KEY", _DEFAULT_STREAM_KEY
109
+ )
110
+ max_len_env = int(os.environ.get("SPANFORGE_REDIS_MAX_LEN", str(_DEFAULT_MAX_LEN)))
111
+ self._max_len = max_len if max_len != _DEFAULT_MAX_LEN else max_len_env
112
+ ttl_env = int(os.environ.get("SPANFORGE_REDIS_TTL_SECONDS", "0"))
113
+ self._ttl = ttl_seconds if ttl_seconds != 0 else ttl_env
114
+ self._decode = decode_responses
115
+ self._client: Optional[object] = None
116
+
117
+ # ------------------------------------------------------------------
118
+ # Async context manager
119
+ # ------------------------------------------------------------------
120
+
121
+ async def __aenter__(self) -> "RedisExporter":
122
+ await self._connect()
123
+ return self
124
+
125
+ async def __aexit__(self, *_: object) -> None:
126
+ await self.close()
127
+
128
+ # ------------------------------------------------------------------
129
+ # Internal
130
+ # ------------------------------------------------------------------
131
+
132
+ async def _connect(self) -> None:
133
+ if self._client is not None:
134
+ return
135
+ aioredis = _require_redis()
136
+ self._client = aioredis.from_url(
137
+ self._url, decode_responses=self._decode
138
+ )
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
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
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: # noqa: BLE001
209
+ pass
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: Optional[object] = 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]:
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