stemtrace 0.1.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 (72) hide show
  1. stemtrace/__init__.py +173 -0
  2. stemtrace/core/__init__.py +15 -0
  3. stemtrace/core/events.py +67 -0
  4. stemtrace/core/exceptions.py +25 -0
  5. stemtrace/core/graph.py +348 -0
  6. stemtrace/core/ports.py +42 -0
  7. stemtrace/library/__init__.py +1 -0
  8. stemtrace/library/bootsteps.py +169 -0
  9. stemtrace/library/config.py +55 -0
  10. stemtrace/library/scrubbing.py +195 -0
  11. stemtrace/library/signals.py +434 -0
  12. stemtrace/library/transports/__init__.py +36 -0
  13. stemtrace/library/transports/memory.py +33 -0
  14. stemtrace/library/transports/redis.py +92 -0
  15. stemtrace/py.typed +0 -0
  16. stemtrace/server/__init__.py +24 -0
  17. stemtrace/server/__main__.py +136 -0
  18. stemtrace/server/api/__init__.py +29 -0
  19. stemtrace/server/api/routes.py +270 -0
  20. stemtrace/server/api/schemas.py +136 -0
  21. stemtrace/server/api/websocket.py +28 -0
  22. stemtrace/server/consumer.py +133 -0
  23. stemtrace/server/fastapi/__init__.py +13 -0
  24. stemtrace/server/fastapi/auth.py +77 -0
  25. stemtrace/server/fastapi/extension.py +121 -0
  26. stemtrace/server/fastapi/router.py +42 -0
  27. stemtrace/server/store.py +260 -0
  28. stemtrace/server/ui/__init__.py +5 -0
  29. stemtrace/server/ui/frontend/biome.json +58 -0
  30. stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
  31. stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
  32. stemtrace/server/ui/frontend/dist/index.html +17 -0
  33. stemtrace/server/ui/frontend/index.html +16 -0
  34. stemtrace/server/ui/frontend/package-lock.json +4049 -0
  35. stemtrace/server/ui/frontend/package.json +46 -0
  36. stemtrace/server/ui/frontend/playwright.config.ts +40 -0
  37. stemtrace/server/ui/frontend/postcss.config.js +5 -0
  38. stemtrace/server/ui/frontend/src/api/client.ts +173 -0
  39. stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
  40. stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
  41. stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
  42. stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
  43. stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
  44. stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
  45. stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
  46. stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
  47. stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
  48. stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
  49. stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
  50. stemtrace/server/ui/frontend/src/index.css +104 -0
  51. stemtrace/server/ui/frontend/src/main.tsx +44 -0
  52. stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
  53. stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
  54. stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
  55. stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
  56. stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
  57. stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
  58. stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
  59. stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
  60. stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
  61. stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
  62. stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
  63. stemtrace/server/ui/frontend/tsconfig.json +32 -0
  64. stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
  65. stemtrace/server/ui/frontend/vite.config.ts +30 -0
  66. stemtrace/server/ui/static.py +56 -0
  67. stemtrace/server/websocket.py +99 -0
  68. stemtrace-0.1.0.dist-info/METADATA +428 -0
  69. stemtrace-0.1.0.dist-info/RECORD +72 -0
  70. stemtrace-0.1.0.dist-info/WHEEL +4 -0
  71. stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
  72. stemtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,434 @@
1
+ """Celery signal handlers for task lifecycle events."""
2
+
3
+ import logging
4
+ import threading
5
+ import traceback as tb_module
6
+ from datetime import datetime, timezone
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from celery.signals import (
10
+ task_failure,
11
+ task_postrun,
12
+ task_prerun,
13
+ task_retry,
14
+ task_revoked,
15
+ task_sent,
16
+ )
17
+
18
+ from stemtrace.core.events import TaskEvent, TaskState
19
+ from stemtrace.core.ports import EventTransport
20
+ from stemtrace.library.config import get_config
21
+ from stemtrace.library.scrubbing import (
22
+ DEFAULT_SENSITIVE_KEYS,
23
+ safe_serialize,
24
+ scrub_args,
25
+ scrub_dict,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from celery import Task
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _transport: EventTransport | None = None
34
+ # Track task IDs that have received PENDING to avoid duplicates from retries
35
+ _pending_emitted: set[str] = set()
36
+ _pending_emitted_lock = threading.RLock()
37
+
38
+
39
+ def _extract_chord_info(chord_attr: Any) -> tuple[str | None, str | None]:
40
+ """Extract chord group_id and callback task_id from chord attribute.
41
+
42
+ Celery's task.request.chord on HEADER tasks contains:
43
+ - The chord callback signature with options.group_id and options.task_id
44
+
45
+ Returns:
46
+ Tuple of (chord_group_id, callback_task_id)
47
+ """
48
+ if chord_attr is None:
49
+ return None, None
50
+
51
+ group_id: str | None = None
52
+ callback_id: str | None = None
53
+
54
+ # Celery Signature object
55
+ if hasattr(chord_attr, "options"):
56
+ opts = chord_attr.options
57
+ if isinstance(opts, dict):
58
+ group_id = opts.get("group_id") or opts.get("group")
59
+ callback_id = opts.get("task_id")
60
+
61
+ # Dict-like (Celery chord dict: {'task': ..., 'options': {'group_id': ..., 'task_id': ...}})
62
+ elif isinstance(chord_attr, dict):
63
+ opts = chord_attr.get("options", {})
64
+ if isinstance(opts, dict):
65
+ group_id = opts.get("group_id") or opts.get("group")
66
+ callback_id = opts.get("task_id")
67
+
68
+ return group_id, callback_id
69
+
70
+
71
+ def _publish_event(event: TaskEvent) -> None:
72
+ """Publish event via transport. Fire-and-forget: logs errors, never raises."""
73
+ if _transport is None:
74
+ logger.warning("stemtrace not initialized, event dropped: %s", event.task_id)
75
+ return
76
+
77
+ try:
78
+ _transport.publish(event)
79
+ except Exception:
80
+ logger.warning(
81
+ "Failed to publish event for task %s", event.task_id, exc_info=True
82
+ )
83
+
84
+
85
+ def _get_scrub_config() -> tuple[
86
+ frozenset[str], frozenset[str] | None, int, bool, bool, bool
87
+ ]:
88
+ """Get scrubbing configuration.
89
+
90
+ Returns:
91
+ Tuple of (sensitive_keys, safe_keys, max_size, scrub_enabled,
92
+ capture_args, capture_result)
93
+ """
94
+ config = get_config()
95
+ if config is None:
96
+ return (DEFAULT_SENSITIVE_KEYS, None, 10240, True, True, True)
97
+
98
+ if config.scrub_sensitive_data:
99
+ sensitive = DEFAULT_SENSITIVE_KEYS | config.additional_sensitive_keys
100
+ safe = config.safe_keys if config.safe_keys else None
101
+ else:
102
+ sensitive = frozenset()
103
+ safe = None
104
+
105
+ return (
106
+ sensitive,
107
+ safe,
108
+ config.max_data_size,
109
+ config.scrub_sensitive_data,
110
+ config.capture_args,
111
+ config.capture_result,
112
+ )
113
+
114
+
115
+ def _scrub_and_serialize_args(
116
+ args: tuple[Any, ...],
117
+ ) -> list[Any] | None:
118
+ """Scrub and serialize positional arguments."""
119
+ sensitive, safe, max_size, scrub_enabled, capture_args, _ = _get_scrub_config()
120
+ if not capture_args:
121
+ return None
122
+
123
+ if scrub_enabled:
124
+ scrubbed = scrub_args(args, sensitive, safe_keys=safe)
125
+ else:
126
+ scrubbed = list(args)
127
+
128
+ result: Any = safe_serialize(scrubbed, max_size, sensitive, safe_keys=safe)
129
+ # safe_serialize may return truncation message string or the list
130
+ if isinstance(result, list):
131
+ return result
132
+ return [result] if result is not None else scrubbed
133
+
134
+
135
+ def _scrub_and_serialize_kwargs(
136
+ kwargs: dict[str, Any],
137
+ ) -> dict[str, Any] | None:
138
+ """Scrub and serialize keyword arguments."""
139
+ sensitive, safe, max_size, scrub_enabled, capture_args, _ = _get_scrub_config()
140
+ if not capture_args:
141
+ return None
142
+
143
+ if scrub_enabled:
144
+ scrubbed = scrub_dict(kwargs, sensitive, safe_keys=safe)
145
+ else:
146
+ scrubbed = kwargs
147
+
148
+ result: Any = safe_serialize(scrubbed, max_size, sensitive, safe_keys=safe)
149
+ # safe_serialize may return truncation message string or the dict
150
+ if isinstance(result, dict):
151
+ return result
152
+ # If truncated, wrap message in dict
153
+ return {"_truncated": result} if result is not None else scrubbed
154
+
155
+
156
+ def _scrub_and_serialize_result(result: Any) -> Any | None:
157
+ """Scrub and serialize task result."""
158
+ sensitive, safe, max_size, _, _, capture_result = _get_scrub_config()
159
+ if not capture_result:
160
+ return None
161
+
162
+ return safe_serialize(result, max_size, sensitive, safe_keys=safe)
163
+
164
+
165
+ def _format_exception(exc: BaseException | None, einfo: Any = None) -> str | None:
166
+ """Format exception to a string message."""
167
+ if exc is not None:
168
+ return f"{type(exc).__name__}: {exc}"
169
+ if einfo is not None:
170
+ return str(einfo.exception) if hasattr(einfo, "exception") else str(einfo)
171
+ return None
172
+
173
+
174
+ def _format_traceback(einfo: Any = None) -> str | None:
175
+ """Format traceback from exception info."""
176
+ if einfo is None:
177
+ return None
178
+
179
+ # billiard ExceptionInfo has traceback attribute
180
+ if hasattr(einfo, "traceback"):
181
+ tb_str: str = einfo.traceback
182
+ return tb_str
183
+ # Standard exception info tuple
184
+ if hasattr(einfo, "tb"):
185
+ return "".join(tb_module.format_tb(einfo.tb))
186
+ return None
187
+
188
+
189
+ def _on_task_prerun(
190
+ task_id: str,
191
+ task: "Task",
192
+ args: tuple[Any, ...],
193
+ kwargs: dict[str, Any],
194
+ **_: Any,
195
+ ) -> None:
196
+ chord_id, chord_callback_id = _extract_chord_info(
197
+ getattr(task.request, "chord", None)
198
+ )
199
+ _publish_event(
200
+ TaskEvent(
201
+ task_id=task_id,
202
+ name=task.name,
203
+ state=TaskState.STARTED,
204
+ timestamp=datetime.now(timezone.utc),
205
+ parent_id=getattr(task.request, "parent_id", None),
206
+ root_id=getattr(task.request, "root_id", None),
207
+ group_id=getattr(task.request, "group", None),
208
+ chord_id=chord_id,
209
+ chord_callback_id=chord_callback_id,
210
+ retries=task.request.retries or 0,
211
+ args=_scrub_and_serialize_args(args),
212
+ kwargs=_scrub_and_serialize_kwargs(kwargs),
213
+ )
214
+ )
215
+
216
+
217
+ def _on_task_postrun(
218
+ task_id: str,
219
+ task: "Task",
220
+ args: tuple[Any, ...],
221
+ kwargs: dict[str, Any],
222
+ retval: Any,
223
+ state: str,
224
+ **_: Any,
225
+ ) -> None:
226
+ del args, kwargs
227
+ if state != "SUCCESS":
228
+ return
229
+
230
+ # Clean up PENDING tracking
231
+ _pending_emitted.discard(task_id)
232
+
233
+ chord_id, chord_callback_id = _extract_chord_info(
234
+ getattr(task.request, "chord", None)
235
+ )
236
+ _publish_event(
237
+ TaskEvent(
238
+ task_id=task_id,
239
+ name=task.name,
240
+ state=TaskState.SUCCESS,
241
+ timestamp=datetime.now(timezone.utc),
242
+ parent_id=getattr(task.request, "parent_id", None),
243
+ root_id=getattr(task.request, "root_id", None),
244
+ group_id=getattr(task.request, "group", None),
245
+ chord_id=chord_id,
246
+ chord_callback_id=chord_callback_id,
247
+ retries=task.request.retries or 0,
248
+ result=_scrub_and_serialize_result(retval),
249
+ )
250
+ )
251
+
252
+
253
+ def _on_task_failure(
254
+ task_id: str,
255
+ exception: BaseException,
256
+ args: tuple[Any, ...],
257
+ kwargs: dict[str, Any],
258
+ traceback: Any,
259
+ einfo: Any,
260
+ sender: "Task",
261
+ **_: Any,
262
+ ) -> None:
263
+ del args, kwargs, traceback
264
+
265
+ # Clean up PENDING tracking
266
+ _pending_emitted.discard(task_id)
267
+
268
+ chord_id, chord_callback_id = _extract_chord_info(
269
+ getattr(sender.request, "chord", None)
270
+ )
271
+ _publish_event(
272
+ TaskEvent(
273
+ task_id=task_id,
274
+ name=sender.name,
275
+ state=TaskState.FAILURE,
276
+ timestamp=datetime.now(timezone.utc),
277
+ parent_id=getattr(sender.request, "parent_id", None),
278
+ root_id=getattr(sender.request, "root_id", None),
279
+ group_id=getattr(sender.request, "group", None),
280
+ chord_id=chord_id,
281
+ chord_callback_id=chord_callback_id,
282
+ retries=sender.request.retries or 0,
283
+ exception=_format_exception(exception),
284
+ traceback=_format_traceback(einfo),
285
+ )
286
+ )
287
+
288
+
289
+ def _on_task_retry(
290
+ sender: "Task",
291
+ request: Any,
292
+ reason: Any,
293
+ einfo: Any,
294
+ **_: Any,
295
+ ) -> None:
296
+ # reason can be an exception or string
297
+ exc_message: str | None = None
298
+ if isinstance(reason, BaseException):
299
+ exc_message = _format_exception(reason)
300
+ elif reason is not None:
301
+ exc_message = str(reason)
302
+
303
+ chord_id, chord_callback_id = _extract_chord_info(getattr(request, "chord", None))
304
+ # Use current retry count (not +1) so RETRY groups with the STARTED that failed
305
+ # Timeline: STARTED(0) → RETRY(0) → STARTED(1) → RETRY(1) → ...
306
+ _publish_event(
307
+ TaskEvent(
308
+ task_id=request.id,
309
+ name=sender.name,
310
+ state=TaskState.RETRY,
311
+ timestamp=datetime.now(timezone.utc),
312
+ parent_id=getattr(request, "parent_id", None),
313
+ root_id=getattr(request, "root_id", None),
314
+ group_id=getattr(request, "group", None),
315
+ chord_id=chord_id,
316
+ chord_callback_id=chord_callback_id,
317
+ retries=request.retries or 0,
318
+ exception=exc_message,
319
+ traceback=_format_traceback(einfo),
320
+ )
321
+ )
322
+
323
+
324
+ def _on_task_revoked(
325
+ request: Any,
326
+ terminated: bool,
327
+ signum: int | None,
328
+ expired: bool,
329
+ sender: "Task",
330
+ **_: Any,
331
+ ) -> None:
332
+ del terminated, signum, expired
333
+ chord_id, chord_callback_id = _extract_chord_info(getattr(request, "chord", None))
334
+ _publish_event(
335
+ TaskEvent(
336
+ task_id=request.id,
337
+ name=sender.name,
338
+ state=TaskState.REVOKED,
339
+ timestamp=datetime.now(timezone.utc),
340
+ parent_id=getattr(request, "parent_id", None),
341
+ root_id=getattr(request, "root_id", None),
342
+ group_id=getattr(request, "group", None),
343
+ chord_id=chord_id,
344
+ chord_callback_id=chord_callback_id,
345
+ retries=getattr(request, "retries", 0) or 0,
346
+ )
347
+ )
348
+
349
+
350
+ def _on_task_sent(
351
+ sender: str | None = None,
352
+ task_id: str | None = None,
353
+ task: str | None = None,
354
+ args: tuple[Any, ...] | None = None,
355
+ kwargs: dict[str, Any] | None = None,
356
+ headers: dict[str, Any] | None = None,
357
+ **_: Any,
358
+ ) -> None:
359
+ """Handle task_sent signal - fires when .delay() or .apply_async() is called.
360
+
361
+ Note: This fires on the SENDER side (where the task is queued), not on the worker.
362
+ It captures the PENDING state before a worker picks up the task.
363
+
364
+ For retries, we skip emitting PENDING since the task is already tracked
365
+ and will get RETRY + STARTED events.
366
+ """
367
+ if task_id is None:
368
+ return
369
+
370
+ # Skip PENDING for retries - check headers first, then our tracking set
371
+ if headers and headers.get("retries", 0) > 0:
372
+ return
373
+
374
+ # Skip if we've already emitted PENDING for this task (handles retry re-queues)
375
+ # Use lock to make check-then-add atomic and prevent duplicates from concurrent threads
376
+ with _pending_emitted_lock:
377
+ if task_id in _pending_emitted:
378
+ return
379
+ _pending_emitted.add(task_id)
380
+
381
+ task_name = task or sender or "unknown"
382
+
383
+ # Extract group_id from headers if available
384
+ group_id = headers.get("group") if headers else None
385
+
386
+ _publish_event(
387
+ TaskEvent(
388
+ task_id=task_id,
389
+ name=task_name,
390
+ state=TaskState.PENDING,
391
+ timestamp=datetime.now(timezone.utc),
392
+ group_id=group_id,
393
+ args=_scrub_and_serialize_args(args) if args else None,
394
+ kwargs=_scrub_and_serialize_kwargs(kwargs) if kwargs else None,
395
+ )
396
+ )
397
+
398
+
399
+ def connect_signals(transport: EventTransport) -> None:
400
+ """Register signal handlers with the given transport."""
401
+ global _transport
402
+ _transport = transport
403
+
404
+ task_sent.connect(_on_task_sent)
405
+ task_prerun.connect(_on_task_prerun)
406
+ task_postrun.connect(_on_task_postrun)
407
+ task_failure.connect(_on_task_failure)
408
+ task_retry.connect(_on_task_retry)
409
+ task_revoked.connect(_on_task_revoked)
410
+
411
+ # Set publisher for bootsteps (RECEIVED events)
412
+ from stemtrace.library.bootsteps import _set_publisher
413
+
414
+ _set_publisher(_publish_event)
415
+
416
+ logger.info("stemtrace signal handlers connected")
417
+
418
+
419
+ def disconnect_signals() -> None:
420
+ """Disconnect all signal handlers."""
421
+ global _transport
422
+ _transport = None
423
+
424
+ task_sent.disconnect(_on_task_sent)
425
+ task_prerun.disconnect(_on_task_prerun)
426
+ task_postrun.disconnect(_on_task_postrun)
427
+ task_failure.disconnect(_on_task_failure)
428
+ task_retry.disconnect(_on_task_retry)
429
+ task_revoked.disconnect(_on_task_revoked)
430
+
431
+ # Clear tracking state
432
+ _pending_emitted.clear()
433
+
434
+ logger.info("stemtrace signal handlers disconnected")
@@ -0,0 +1,36 @@
1
+ """Broker-agnostic transport factory."""
2
+
3
+ from typing import TYPE_CHECKING
4
+ from urllib.parse import urlparse
5
+
6
+ from stemtrace.core.exceptions import UnsupportedBrokerError
7
+
8
+ if TYPE_CHECKING:
9
+ from stemtrace.core.ports import EventTransport
10
+
11
+ _SCHEME_ALIASES: dict[str, str] = {
12
+ "rediss": "redis",
13
+ "amqps": "amqp",
14
+ }
15
+
16
+
17
+ def get_transport(
18
+ url: str, prefix: str = "stemtrace", ttl: int = 86400
19
+ ) -> "EventTransport":
20
+ """Create a transport from a broker URL."""
21
+ scheme = urlparse(url).scheme.lower()
22
+ scheme = _SCHEME_ALIASES.get(scheme, scheme)
23
+
24
+ if scheme == "redis":
25
+ from stemtrace.library.transports.redis import RedisTransport
26
+
27
+ return RedisTransport.from_url(url, prefix=prefix, ttl=ttl)
28
+ elif scheme == "memory":
29
+ from stemtrace.library.transports.memory import MemoryTransport
30
+
31
+ return MemoryTransport()
32
+ else:
33
+ raise UnsupportedBrokerError(scheme)
34
+
35
+
36
+ __all__ = ["get_transport"]
@@ -0,0 +1,33 @@
1
+ """In-memory transport for testing."""
2
+
3
+ from collections.abc import Iterator
4
+ from typing import ClassVar
5
+
6
+ from typing_extensions import Self
7
+
8
+ from stemtrace.core.events import TaskEvent
9
+
10
+
11
+ class MemoryTransport:
12
+ """In-memory event transport. Events stored in class-level list for test inspection."""
13
+
14
+ events: ClassVar[list[TaskEvent]] = []
15
+
16
+ def publish(self, event: TaskEvent) -> None:
17
+ """Store event in memory."""
18
+ MemoryTransport.events.append(event)
19
+
20
+ def consume(self) -> Iterator[TaskEvent]:
21
+ """Yield all stored events."""
22
+ yield from MemoryTransport.events
23
+
24
+ @classmethod
25
+ def from_url(cls, url: str) -> Self:
26
+ """Create transport (ignores URL)."""
27
+ del url
28
+ return cls()
29
+
30
+ @classmethod
31
+ def clear(cls) -> None:
32
+ """Clear all stored events."""
33
+ cls.events.clear()
@@ -0,0 +1,92 @@
1
+ """Redis transport using Redis Streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from typing_extensions import Self
9
+
10
+ from stemtrace.core.events import TaskEvent
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+
15
+ from redis import Redis
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class RedisTransport:
21
+ """Redis Streams-based event transport (XADD/XREAD)."""
22
+
23
+ def __init__(self, client: Redis[Any], prefix: str, ttl: int) -> None:
24
+ """Initialize Redis transport with client and stream configuration."""
25
+ self._client = client
26
+ self._ttl = ttl
27
+ self._stream_key = f"{prefix}:events"
28
+ self._maxlen = max(ttl, 10000)
29
+
30
+ @property
31
+ def client(self) -> Redis[Any]:
32
+ """The Redis client."""
33
+ return self._client
34
+
35
+ @property
36
+ def stream_key(self) -> str:
37
+ """The stream key."""
38
+ return self._stream_key
39
+
40
+ @property
41
+ def ttl(self) -> int:
42
+ """TTL in seconds."""
43
+ return self._ttl
44
+
45
+ def publish(self, event: TaskEvent) -> None:
46
+ """Publish event. Fire-and-forget: logs errors, never raises."""
47
+ try:
48
+ self._client.xadd(
49
+ self._stream_key,
50
+ {"data": event.model_dump_json()},
51
+ maxlen=self._maxlen,
52
+ approximate=True,
53
+ )
54
+ except Exception:
55
+ logger.warning(
56
+ "Failed to publish event %s to Redis", event.task_id, exc_info=True
57
+ )
58
+
59
+ def consume(self, last_id: str = "0") -> Iterator[TaskEvent]:
60
+ """Blocking iterator that yields events as they arrive."""
61
+ current_id = last_id
62
+ while True:
63
+ results = self._client.xread(
64
+ {self._stream_key: current_id},
65
+ block=5000,
66
+ count=100,
67
+ )
68
+ if not results:
69
+ continue
70
+
71
+ for _stream_name, messages in results:
72
+ for message_id, fields in messages:
73
+ current_id = (
74
+ message_id.decode()
75
+ if isinstance(message_id, bytes)
76
+ else message_id
77
+ )
78
+ data = fields.get(b"data") or fields.get("data")
79
+ if data:
80
+ data_str = data.decode() if isinstance(data, bytes) else data
81
+ yield TaskEvent.model_validate_json(data_str)
82
+
83
+ @classmethod
84
+ def from_url(cls, url: str, prefix: str = "stemtrace", ttl: int = 86400) -> Self:
85
+ """Create transport from Redis URL."""
86
+ from redis import Redis as RedisClient
87
+
88
+ return cls(
89
+ client=RedisClient.from_url(url, decode_responses=False),
90
+ prefix=prefix,
91
+ ttl=ttl,
92
+ )
stemtrace/py.typed ADDED
File without changes
@@ -0,0 +1,24 @@
1
+ """Server component - Web server for visualization."""
2
+
3
+ from stemtrace.server.consumer import AsyncEventConsumer, EventConsumer
4
+ from stemtrace.server.fastapi import (
5
+ StemtraceExtension,
6
+ create_router,
7
+ no_auth,
8
+ require_api_key,
9
+ require_basic_auth,
10
+ )
11
+ from stemtrace.server.store import GraphStore
12
+ from stemtrace.server.websocket import WebSocketManager
13
+
14
+ __all__ = [
15
+ "AsyncEventConsumer",
16
+ "EventConsumer",
17
+ "GraphStore",
18
+ "StemtraceExtension",
19
+ "WebSocketManager",
20
+ "create_router",
21
+ "no_auth",
22
+ "require_api_key",
23
+ "require_basic_auth",
24
+ ]