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,169 @@
1
+ """Celery bootsteps for capturing task lifecycle events.
2
+
3
+ Bootsteps hook into Celery's worker consumer pipeline to capture
4
+ events that don't have dedicated signals (like RECEIVED).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from datetime import datetime, timezone
11
+ from typing import TYPE_CHECKING, Any, ClassVar
12
+
13
+ from celery import bootsteps
14
+
15
+ from stemtrace.core.events import TaskEvent, TaskState
16
+
17
+ if TYPE_CHECKING:
18
+ from celery.worker.consumer import Consumer
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Import here to avoid circular imports
23
+ _publish_event: Any = None
24
+
25
+
26
+ def _set_publisher(publisher: Any) -> None:
27
+ """Set the event publisher function (called from signals module)."""
28
+ global _publish_event
29
+ _publish_event = publisher
30
+
31
+
32
+ # Celery's bootsteps module is not fully typed
33
+ class ReceivedEventStep(bootsteps.ConsumerStep): # type: ignore[misc]
34
+ """Bootstep that emits RECEIVED events when tasks are received by worker.
35
+
36
+ This hooks into the consumer's task message handling to capture the moment
37
+ a task message is received from the broker, before it's executed.
38
+ """
39
+
40
+ requires: ClassVar[set[str]] = {"celery.worker.consumer.tasks:Tasks"}
41
+
42
+ def __init__(
43
+ self,
44
+ consumer: Consumer,
45
+ **kwargs: Any,
46
+ ) -> None:
47
+ """Initialize the bootstep."""
48
+ super().__init__(consumer, **kwargs)
49
+ self._original_strategy: dict[str, Any] = {}
50
+
51
+ def start(self, consumer: Consumer) -> None:
52
+ """Wrap task strategies to emit RECEIVED events."""
53
+ logger.debug("ReceivedEventStep: wrapping task strategies")
54
+
55
+ # Wrap each task's strategy to emit RECEIVED before execution
56
+ for name, strategy in list(consumer.strategies.items()):
57
+ if name not in self._original_strategy:
58
+ self._original_strategy[name] = strategy
59
+ consumer.strategies[name] = self._wrap_strategy(name, strategy)
60
+
61
+ def stop(self, consumer: Consumer) -> None:
62
+ """Restore original strategies."""
63
+ for name, strategy in self._original_strategy.items():
64
+ if name in consumer.strategies:
65
+ consumer.strategies[name] = strategy
66
+ self._original_strategy.clear()
67
+
68
+ def _wrap_strategy(self, task_name: str, strategy: Any) -> Any:
69
+ """Wrap a task strategy to emit RECEIVED before execution."""
70
+
71
+ def wrapped_strategy(
72
+ message: Any,
73
+ body: Any,
74
+ ack: Any,
75
+ reject: Any,
76
+ callbacks: Any,
77
+ **kwargs: Any,
78
+ ) -> Any:
79
+ """Emit RECEIVED event before delegating to original strategy."""
80
+ # Emit RECEIVED event
81
+ try:
82
+ self._emit_received(task_name, body, message)
83
+ except Exception:
84
+ logger.debug("Failed to emit RECEIVED event", exc_info=True)
85
+
86
+ # Call original strategy
87
+ return strategy(message, body, ack, reject, callbacks, **kwargs)
88
+
89
+ return wrapped_strategy
90
+
91
+ def _emit_received(self, task_name: str, body: Any, message: Any) -> None:
92
+ """Emit a RECEIVED event for the task.
93
+
94
+ Skips retries since they already have RETRY + STARTED events.
95
+ """
96
+ if _publish_event is None:
97
+ return
98
+
99
+ # Extract task ID and retries from body (can be tuple or dict format)
100
+ task_id: str | None = None
101
+ parent_id: str | None = None
102
+ root_id: str | None = None
103
+ group_id: str | None = None
104
+ chord_id: str | None = None
105
+ retries: int = 0
106
+
107
+ if isinstance(body, list | tuple) and len(body) >= 3:
108
+ # Old format: (args, kwargs, embed)
109
+ embed = body[2] if len(body) > 2 else {}
110
+ if isinstance(embed, dict):
111
+ task_id = embed.get("id")
112
+ parent_id = embed.get("parent_id")
113
+ root_id = embed.get("root_id")
114
+ group_id = embed.get("group")
115
+ chord_id = embed.get("chord")
116
+ retries = embed.get("retries", 0) or 0
117
+ elif isinstance(body, dict):
118
+ # New format: {'task': ..., 'id': ..., ...}
119
+ task_id = body.get("id")
120
+ parent_id = body.get("parent_id")
121
+ root_id = body.get("root_id")
122
+ group_id = body.get("group")
123
+ chord_id = body.get("chord")
124
+ retries = body.get("retries", 0) or 0
125
+
126
+ # Fallback to message headers/properties
127
+ if hasattr(message, "headers"):
128
+ headers = message.headers or {}
129
+ if task_id is None:
130
+ task_id = headers.get("id")
131
+ parent_id = parent_id or headers.get("parent_id")
132
+ root_id = root_id or headers.get("root_id")
133
+ group_id = group_id or headers.get("group")
134
+ chord_id = chord_id or headers.get("chord")
135
+ if retries == 0:
136
+ retries = headers.get("retries", 0) or 0
137
+
138
+ if task_id is None:
139
+ logger.debug("Could not extract task_id for RECEIVED event")
140
+ return
141
+
142
+ # Skip RECEIVED for retries - they already have RETRY events
143
+ if retries > 0:
144
+ logger.debug("Skipping RECEIVED for retry %d of task %s", retries, task_id)
145
+ return
146
+
147
+ _publish_event(
148
+ TaskEvent(
149
+ task_id=task_id,
150
+ name=task_name,
151
+ state=TaskState.RECEIVED,
152
+ timestamp=datetime.now(timezone.utc),
153
+ parent_id=parent_id,
154
+ root_id=root_id,
155
+ group_id=group_id,
156
+ chord_id=chord_id,
157
+ )
158
+ )
159
+ logger.debug("Emitted RECEIVED for task %s", task_id)
160
+
161
+
162
+ def register_bootsteps(app: Any) -> None:
163
+ """Register stemtrace bootsteps with the Celery app.
164
+
165
+ Args:
166
+ app: The Celery application instance.
167
+ """
168
+ app.steps["consumer"].add(ReceivedEventStep)
169
+ logger.debug("Registered ReceivedEventStep bootstep")
@@ -0,0 +1,55 @@
1
+ """Configuration for stemtrace library."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class StemtraceConfig(BaseModel):
7
+ """Frozen configuration for stemtrace initialization.
8
+
9
+ Attributes:
10
+ transport_url: Broker URL for event transport.
11
+ prefix: Redis key prefix for stemtrace data.
12
+ ttl: Time-to-live for stored events in seconds.
13
+ capture_args: Whether to capture task args/kwargs.
14
+ capture_result: Whether to capture task return values.
15
+ max_data_size: Maximum size in bytes for serialized data.
16
+ scrub_sensitive_data: Whether to scrub sensitive keys.
17
+ additional_sensitive_keys: Extra keys to treat as sensitive.
18
+ safe_keys: Keys to never scrub (overrides sensitive).
19
+ """
20
+
21
+ model_config = ConfigDict(frozen=True)
22
+
23
+ transport_url: str
24
+ prefix: str = "stemtrace"
25
+ ttl: int = 86400
26
+
27
+ # Data capture options (all enabled by default)
28
+ capture_args: bool = True
29
+ capture_result: bool = True
30
+ max_data_size: int = 10240 # 10KB
31
+
32
+ # Scrubbing options (enabled by default)
33
+ scrub_sensitive_data: bool = True
34
+ additional_sensitive_keys: frozenset[str] = Field(default_factory=frozenset)
35
+ safe_keys: frozenset[str] = Field(default_factory=frozenset)
36
+
37
+
38
+ _config: StemtraceConfig | None = None
39
+
40
+
41
+ def get_config() -> StemtraceConfig | None:
42
+ """Get the active configuration, or None if not initialized."""
43
+ return _config
44
+
45
+
46
+ def set_config(config: StemtraceConfig) -> None:
47
+ """Set the active configuration."""
48
+ global _config
49
+ _config = config
50
+
51
+
52
+ def _reset_config() -> None:
53
+ """Reset configuration. For testing only."""
54
+ global _config
55
+ _config = None
@@ -0,0 +1,195 @@
1
+ """Sensitive data scrubbing for task arguments and results.
2
+
3
+ Implements Sentry-style data scrubbing with a configurable denylist of
4
+ sensitive key patterns. Values matching sensitive keys are replaced with
5
+ a filtered placeholder.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ FILTERED = "[Filtered]"
17
+
18
+ # Default sensitive key patterns (case-insensitive, partial match)
19
+ DEFAULT_SENSITIVE_KEYS: frozenset[str] = frozenset(
20
+ {
21
+ # Authentication
22
+ "password",
23
+ "passwd",
24
+ "pwd",
25
+ "pass",
26
+ "secret",
27
+ "api_key",
28
+ "apikey",
29
+ "api_secret",
30
+ "token",
31
+ "access_token",
32
+ "refresh_token",
33
+ "auth_token",
34
+ "bearer",
35
+ "authorization",
36
+ "auth",
37
+ "credentials",
38
+ "private_key",
39
+ "private",
40
+ # Financial/PII
41
+ "credit_card",
42
+ "card_number",
43
+ "cvv",
44
+ "ssn",
45
+ "social_security",
46
+ # Session
47
+ "cookie",
48
+ "session",
49
+ "sessionid",
50
+ "csrf",
51
+ "csrf_token",
52
+ }
53
+ )
54
+
55
+
56
+ def _is_sensitive_key(
57
+ key: str,
58
+ sensitive_keys: frozenset[str],
59
+ safe_keys: frozenset[str] | None = None,
60
+ ) -> bool:
61
+ """Check if a key matches any sensitive pattern.
62
+
63
+ Uses case-insensitive partial matching:
64
+ - 'user_password' matches 'password'
65
+ - 'API_KEY' matches 'api_key'
66
+
67
+ Args:
68
+ key: The key name to check.
69
+ sensitive_keys: Set of sensitive key patterns.
70
+ safe_keys: Optional set of keys to never scrub.
71
+
72
+ Returns:
73
+ True if the key should be scrubbed.
74
+ """
75
+ if safe_keys and key.lower() in {k.lower() for k in safe_keys}:
76
+ return False
77
+
78
+ key_lower = key.lower()
79
+ return any(pattern in key_lower for pattern in sensitive_keys)
80
+
81
+
82
+ def scrub_dict(
83
+ data: dict[str, Any],
84
+ sensitive_keys: frozenset[str] = DEFAULT_SENSITIVE_KEYS,
85
+ additional_keys: frozenset[str] | None = None,
86
+ safe_keys: frozenset[str] | None = None,
87
+ ) -> dict[str, Any]:
88
+ """Recursively scrub sensitive keys from a dictionary.
89
+
90
+ Args:
91
+ data: Dictionary to scrub.
92
+ sensitive_keys: Base set of sensitive key patterns.
93
+ additional_keys: Extra keys to treat as sensitive.
94
+ safe_keys: Keys to never scrub (overrides sensitive).
95
+
96
+ Returns:
97
+ New dictionary with sensitive values replaced by FILTERED.
98
+
99
+ Example:
100
+ >>> scrub_dict({"password": "secret123", "name": "Alice"})
101
+ {'password': '[Filtered]', 'name': 'Alice'}
102
+ """
103
+ all_sensitive = (
104
+ sensitive_keys | additional_keys if additional_keys else sensitive_keys
105
+ )
106
+
107
+ result: dict[str, Any] = {}
108
+ for key, value in data.items():
109
+ if _is_sensitive_key(key, all_sensitive, safe_keys):
110
+ result[key] = FILTERED
111
+ else:
112
+ result[key] = _scrub_value(value, all_sensitive, safe_keys)
113
+
114
+ return result
115
+
116
+
117
+ def _scrub_value(
118
+ value: Any,
119
+ sensitive_keys: frozenset[str],
120
+ safe_keys: frozenset[str] | None = None,
121
+ ) -> Any:
122
+ """Recursively scrub a value (handles nested dicts and lists)."""
123
+ if isinstance(value, dict):
124
+ return scrub_dict(value, sensitive_keys, safe_keys=safe_keys)
125
+ elif isinstance(value, list):
126
+ return [_scrub_value(item, sensitive_keys, safe_keys) for item in value]
127
+ elif isinstance(value, tuple):
128
+ return tuple(_scrub_value(item, sensitive_keys, safe_keys) for item in value)
129
+ else:
130
+ return value
131
+
132
+
133
+ def scrub_args(
134
+ args: tuple[Any, ...],
135
+ sensitive_keys: frozenset[str] = DEFAULT_SENSITIVE_KEYS,
136
+ additional_keys: frozenset[str] | None = None,
137
+ safe_keys: frozenset[str] | None = None,
138
+ ) -> list[Any]:
139
+ """Scrub positional arguments (recursively handles nested structures).
140
+
141
+ Args:
142
+ args: Tuple of positional arguments.
143
+ sensitive_keys: Base set of sensitive key patterns.
144
+ additional_keys: Extra keys to treat as sensitive.
145
+ safe_keys: Keys to never scrub.
146
+
147
+ Returns:
148
+ List of scrubbed arguments.
149
+ """
150
+ all_sensitive = (
151
+ sensitive_keys | additional_keys if additional_keys else sensitive_keys
152
+ )
153
+ return [_scrub_value(arg, all_sensitive, safe_keys) for arg in args]
154
+
155
+
156
+ def safe_serialize(
157
+ value: Any,
158
+ max_size: int = 10240,
159
+ sensitive_keys: frozenset[str] = DEFAULT_SENSITIVE_KEYS,
160
+ additional_keys: frozenset[str] | None = None,
161
+ safe_keys: frozenset[str] | None = None,
162
+ ) -> Any:
163
+ """Serialize a value to JSON-compatible format with size limit and scrubbing.
164
+
165
+ Args:
166
+ value: Value to serialize.
167
+ max_size: Maximum size in bytes before truncation.
168
+ sensitive_keys: Base set of sensitive key patterns.
169
+ additional_keys: Extra keys to treat as sensitive.
170
+ safe_keys: Keys to never scrub.
171
+
172
+ Returns:
173
+ JSON-serializable value, or string representation if serialization fails.
174
+ """
175
+ all_sensitive = (
176
+ sensitive_keys | additional_keys if additional_keys else sensitive_keys
177
+ )
178
+
179
+ # First scrub the value
180
+ scrubbed = _scrub_value(value, all_sensitive, safe_keys)
181
+
182
+ # Try to serialize to check size and ensure it's JSON-compatible
183
+ try:
184
+ serialized = json.dumps(scrubbed, default=str)
185
+ if len(serialized) > max_size:
186
+ return f"[Truncated: {len(serialized)} bytes > {max_size} max]"
187
+ # Return the original scrubbed value (not the JSON string)
188
+ return scrubbed
189
+ except (TypeError, ValueError) as e:
190
+ logger.debug("Failed to serialize value: %s", e)
191
+ # Fall back to string representation
192
+ str_repr = str(scrubbed)
193
+ if len(str_repr) > max_size:
194
+ return f"[Truncated: {len(str_repr)} bytes > {max_size} max]"
195
+ return str_repr