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,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
|