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.
- stemtrace/__init__.py +173 -0
- stemtrace/core/__init__.py +15 -0
- stemtrace/core/events.py +67 -0
- stemtrace/core/exceptions.py +25 -0
- stemtrace/core/graph.py +348 -0
- stemtrace/core/ports.py +42 -0
- stemtrace/library/__init__.py +1 -0
- stemtrace/library/bootsteps.py +169 -0
- stemtrace/library/config.py +55 -0
- stemtrace/library/scrubbing.py +195 -0
- stemtrace/library/signals.py +434 -0
- stemtrace/library/transports/__init__.py +36 -0
- stemtrace/library/transports/memory.py +33 -0
- stemtrace/library/transports/redis.py +92 -0
- stemtrace/py.typed +0 -0
- stemtrace/server/__init__.py +24 -0
- stemtrace/server/__main__.py +136 -0
- stemtrace/server/api/__init__.py +29 -0
- stemtrace/server/api/routes.py +270 -0
- stemtrace/server/api/schemas.py +136 -0
- stemtrace/server/api/websocket.py +28 -0
- stemtrace/server/consumer.py +133 -0
- stemtrace/server/fastapi/__init__.py +13 -0
- stemtrace/server/fastapi/auth.py +77 -0
- stemtrace/server/fastapi/extension.py +121 -0
- stemtrace/server/fastapi/router.py +42 -0
- stemtrace/server/store.py +260 -0
- stemtrace/server/ui/__init__.py +5 -0
- stemtrace/server/ui/frontend/biome.json +58 -0
- stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
- stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
- stemtrace/server/ui/frontend/dist/index.html +17 -0
- stemtrace/server/ui/frontend/index.html +16 -0
- stemtrace/server/ui/frontend/package-lock.json +4049 -0
- stemtrace/server/ui/frontend/package.json +46 -0
- stemtrace/server/ui/frontend/playwright.config.ts +40 -0
- stemtrace/server/ui/frontend/postcss.config.js +5 -0
- stemtrace/server/ui/frontend/src/api/client.ts +173 -0
- stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
- stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
- stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
- stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
- stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
- stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
- stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
- stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
- stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
- stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
- stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
- stemtrace/server/ui/frontend/src/index.css +104 -0
- stemtrace/server/ui/frontend/src/main.tsx +44 -0
- stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
- stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
- stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
- stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
- stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
- stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
- stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
- stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
- stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
- stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
- stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
- stemtrace/server/ui/frontend/tsconfig.json +32 -0
- stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
- stemtrace/server/ui/frontend/vite.config.ts +30 -0
- stemtrace/server/ui/static.py +56 -0
- stemtrace/server/websocket.py +99 -0
- stemtrace-0.1.0.dist-info/METADATA +428 -0
- stemtrace-0.1.0.dist-info/RECORD +72 -0
- stemtrace-0.1.0.dist-info/WHEEL +4 -0
- stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|