steadwing 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.
- steadwing/__init__.py +42 -0
- steadwing/breadcrumbs.py +169 -0
- steadwing/client.py +215 -0
- steadwing/hooks.py +172 -0
- steadwing/integrations/__init__.py +1 -0
- steadwing/integrations/asyncio_hook.py +78 -0
- steadwing/integrations/django.py +89 -0
- steadwing/integrations/django_db.py +122 -0
- steadwing/integrations/fastapi.py +99 -0
- steadwing/integrations/flask.py +111 -0
- steadwing/integrations/sqlalchemy.py +82 -0
- steadwing/logging_handler.py +85 -0
- steadwing/scrubber.py +52 -0
- steadwing/transport.py +167 -0
- steadwing/types.py +68 -0
- steadwing-0.1.0.dist-info/METADATA +114 -0
- steadwing-0.1.0.dist-info/RECORD +19 -0
- steadwing-0.1.0.dist-info/WHEEL +4 -0
- steadwing-0.1.0.dist-info/licenses/LICENSE +201 -0
steadwing/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Steadwing Python SDK — auto-captures exceptions, error logs, and HTTP breadcrumbs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from steadwing.client import SteadwingClient
|
|
6
|
+
from steadwing.types import SDK_VERSION
|
|
7
|
+
|
|
8
|
+
__version__ = SDK_VERSION
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def init(
|
|
12
|
+
api_key: str,
|
|
13
|
+
service: str,
|
|
14
|
+
env: str = "PROD",
|
|
15
|
+
enabled: bool = True,
|
|
16
|
+
) -> SteadwingClient:
|
|
17
|
+
"""Initialize the Steadwing SDK.
|
|
18
|
+
|
|
19
|
+
Idempotent — returns existing client if already initialized.
|
|
20
|
+
Pure auto-capture: no manual API needed after init.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: Your Steadwing API key (e.g. "st_...").
|
|
24
|
+
service: Name of your service (e.g. "payment-service").
|
|
25
|
+
env: Deployment environment (e.g. "PROD", "DEV").
|
|
26
|
+
enabled: Whether to enable capture (default True). Set False to disable.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The initialized SteadwingClient instance.
|
|
30
|
+
"""
|
|
31
|
+
existing = SteadwingClient.get_instance()
|
|
32
|
+
if existing is not None:
|
|
33
|
+
return existing
|
|
34
|
+
|
|
35
|
+
client = SteadwingClient(
|
|
36
|
+
api_key=api_key,
|
|
37
|
+
service=service,
|
|
38
|
+
env=env,
|
|
39
|
+
enabled=enabled,
|
|
40
|
+
)
|
|
41
|
+
SteadwingClient.set_instance(client)
|
|
42
|
+
return client
|
steadwing/breadcrumbs.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""HTTP breadcrumb capture via http.client monkey-patching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_breadcrumbs: deque[dict[str, Any]] = deque(maxlen=100)
|
|
12
|
+
_lock = threading.Lock()
|
|
13
|
+
_original_http_request: Any = None
|
|
14
|
+
_original_https_request: Any = None
|
|
15
|
+
_original_http_getresponse: Any = None
|
|
16
|
+
_original_https_getresponse: Any = None
|
|
17
|
+
_patched = False
|
|
18
|
+
_in_sdk_call = threading.local()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_breadcrumbs() -> list[dict[str, Any]]:
|
|
22
|
+
"""Return a copy of the current breadcrumbs."""
|
|
23
|
+
with _lock:
|
|
24
|
+
return list(_breadcrumbs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clear_breadcrumbs() -> None:
|
|
28
|
+
"""Clear all breadcrumbs."""
|
|
29
|
+
with _lock:
|
|
30
|
+
_breadcrumbs.clear()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def add_breadcrumb(breadcrumb: dict[str, Any]) -> None:
|
|
34
|
+
"""Add a breadcrumb to the rolling buffer."""
|
|
35
|
+
with _lock:
|
|
36
|
+
_breadcrumbs.append(breadcrumb)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def mark_sdk_call():
|
|
40
|
+
"""Mark current thread as making an SDK-internal HTTP call (skip breadcrumb)."""
|
|
41
|
+
_in_sdk_call.active = True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unmark_sdk_call():
|
|
45
|
+
"""Unmark current thread."""
|
|
46
|
+
_in_sdk_call.active = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _patched_request(original_fn: Any):
|
|
50
|
+
"""Create a patched request function that records HTTP breadcrumbs."""
|
|
51
|
+
|
|
52
|
+
def wrapper(self: Any, method: str, url: str, body: Any = None, headers: Any = None, **kwargs: Any) -> Any:
|
|
53
|
+
if headers is None:
|
|
54
|
+
headers = {}
|
|
55
|
+
if getattr(_in_sdk_call, "active", False):
|
|
56
|
+
return original_fn(self, method, url, body=body, headers=headers, **kwargs)
|
|
57
|
+
start = time.time()
|
|
58
|
+
try:
|
|
59
|
+
result = original_fn(self, method, url, body=body, headers=headers, **kwargs)
|
|
60
|
+
duration_ms = (time.time() - start) * 1000
|
|
61
|
+
breadcrumb = {
|
|
62
|
+
"type": "http",
|
|
63
|
+
"timestamp": start,
|
|
64
|
+
"data": {
|
|
65
|
+
"method": method,
|
|
66
|
+
"url": f"{self.host}{url}",
|
|
67
|
+
"duration_ms": round(duration_ms, 2),
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
# Stash the breadcrumb on the connection so the matching
|
|
71
|
+
# getresponse() call can fill in the status code.
|
|
72
|
+
try:
|
|
73
|
+
self._steadwing_breadcrumb = breadcrumb
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
add_breadcrumb(breadcrumb)
|
|
77
|
+
return result
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
duration_ms = (time.time() - start) * 1000
|
|
80
|
+
add_breadcrumb({
|
|
81
|
+
"type": "http",
|
|
82
|
+
"timestamp": start,
|
|
83
|
+
"data": {
|
|
84
|
+
"method": method,
|
|
85
|
+
"url": f"{self.host}{url}",
|
|
86
|
+
"duration_ms": round(duration_ms, 2),
|
|
87
|
+
"error": repr(exc)[:256],
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
return wrapper
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _patched_getresponse(original_fn: Any):
|
|
96
|
+
"""Create a patched getresponse that records the HTTP status code."""
|
|
97
|
+
|
|
98
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
99
|
+
response = original_fn(self, *args, **kwargs)
|
|
100
|
+
try:
|
|
101
|
+
breadcrumb = getattr(self, "_steadwing_breadcrumb", None)
|
|
102
|
+
if breadcrumb is not None:
|
|
103
|
+
status = getattr(response, "status", None)
|
|
104
|
+
if status is not None:
|
|
105
|
+
breadcrumb["data"]["status_code"] = status
|
|
106
|
+
self._steadwing_breadcrumb = None
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
return wrapper
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def patch_http_client() -> None:
|
|
115
|
+
"""Monkey-patch http.client to capture outgoing HTTP requests as breadcrumbs."""
|
|
116
|
+
global _original_http_request, _original_https_request, _patched
|
|
117
|
+
global _original_http_getresponse, _original_https_getresponse
|
|
118
|
+
|
|
119
|
+
if _patched:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
_original_http_request = http.client.HTTPConnection.request
|
|
124
|
+
http.client.HTTPConnection.request = _patched_request(_original_http_request)
|
|
125
|
+
_original_http_getresponse = http.client.HTTPConnection.getresponse
|
|
126
|
+
http.client.HTTPConnection.getresponse = _patched_getresponse(_original_http_getresponse)
|
|
127
|
+
|
|
128
|
+
# HTTPSConnection normally inherits request/getresponse from HTTPConnection,
|
|
129
|
+
# so the patches above already cover HTTPS. Only patch it separately on the
|
|
130
|
+
# (rare) Python builds where it defines its own — patching inherited methods
|
|
131
|
+
# again would double-wrap and emit two breadcrumbs per HTTPS request.
|
|
132
|
+
https_conn = getattr(http.client, "HTTPSConnection", None)
|
|
133
|
+
if https_conn is not None:
|
|
134
|
+
if "request" in https_conn.__dict__:
|
|
135
|
+
_original_https_request = https_conn.request
|
|
136
|
+
https_conn.request = _patched_request(_original_https_request)
|
|
137
|
+
if "getresponse" in https_conn.__dict__:
|
|
138
|
+
_original_https_getresponse = https_conn.getresponse
|
|
139
|
+
https_conn.getresponse = _patched_getresponse(_original_https_getresponse)
|
|
140
|
+
|
|
141
|
+
_patched = True
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def unpatch_http_client() -> None:
|
|
147
|
+
"""Restore original http.client methods."""
|
|
148
|
+
global _original_http_request, _original_https_request, _patched
|
|
149
|
+
global _original_http_getresponse, _original_https_getresponse
|
|
150
|
+
|
|
151
|
+
if not _patched:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
if _original_http_request is not None:
|
|
156
|
+
http.client.HTTPConnection.request = _original_http_request
|
|
157
|
+
if _original_http_getresponse is not None:
|
|
158
|
+
http.client.HTTPConnection.getresponse = _original_http_getresponse
|
|
159
|
+
|
|
160
|
+
https_conn = getattr(http.client, "HTTPSConnection", None)
|
|
161
|
+
if https_conn is not None:
|
|
162
|
+
if _original_https_request is not None:
|
|
163
|
+
https_conn.request = _original_https_request
|
|
164
|
+
if _original_https_getresponse is not None:
|
|
165
|
+
https_conn.getresponse = _original_https_getresponse
|
|
166
|
+
|
|
167
|
+
_patched = False
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
steadwing/client.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Main SteadwingClient class — singleton that orchestrates all SDK functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from steadwing.breadcrumbs import patch_http_client
|
|
12
|
+
from steadwing.hooks import patch_hooks
|
|
13
|
+
from steadwing.integrations.asyncio_hook import patch_asyncio
|
|
14
|
+
from steadwing.logging_handler import install_logging_handler
|
|
15
|
+
from steadwing.transport import Transport
|
|
16
|
+
from steadwing.types import base_event, build_runtime_info
|
|
17
|
+
|
|
18
|
+
_DEFAULT_BACKEND_URL = "https://api.steadwing.com"
|
|
19
|
+
_HEARTBEAT_INTERVAL = 60.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SteadwingClient:
|
|
23
|
+
"""Core SDK client that manages patching, event capture, and transport."""
|
|
24
|
+
|
|
25
|
+
_instance: SteadwingClient | None = None
|
|
26
|
+
_init_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: str,
|
|
31
|
+
service: str,
|
|
32
|
+
env: str = "PROD",
|
|
33
|
+
enabled: bool = True,
|
|
34
|
+
):
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.service = service
|
|
37
|
+
self.env = env
|
|
38
|
+
self.enabled = enabled
|
|
39
|
+
self.backend_url = os.environ.get("STEADWING_BACKEND_URL", _DEFAULT_BACKEND_URL)
|
|
40
|
+
self.runtime = build_runtime_info()
|
|
41
|
+
self._transport: Transport | None = None
|
|
42
|
+
self._heartbeat_timer: threading.Timer | None = None
|
|
43
|
+
self._shutdown = False
|
|
44
|
+
|
|
45
|
+
if self.enabled:
|
|
46
|
+
self._setup()
|
|
47
|
+
|
|
48
|
+
def _setup(self) -> None:
|
|
49
|
+
"""Set up transport, patches, and heartbeat."""
|
|
50
|
+
# Start transport
|
|
51
|
+
self._transport = Transport(api_key=self.api_key, backend_url=self.backend_url)
|
|
52
|
+
self._transport.start()
|
|
53
|
+
|
|
54
|
+
# Install exception hooks
|
|
55
|
+
patch_hooks(on_exception=self._handle_exception)
|
|
56
|
+
|
|
57
|
+
# Install logging handler
|
|
58
|
+
install_logging_handler(on_log_event=self._handle_log_event)
|
|
59
|
+
|
|
60
|
+
# Patch http.client for breadcrumbs
|
|
61
|
+
patch_http_client()
|
|
62
|
+
|
|
63
|
+
# Capture unhandled errors from asyncio tasks / event loop
|
|
64
|
+
patch_asyncio(on_exception=self._handle_exception)
|
|
65
|
+
|
|
66
|
+
# Auto-detect and patch supported frameworks
|
|
67
|
+
self._try_patch_frameworks()
|
|
68
|
+
|
|
69
|
+
# Start heartbeat
|
|
70
|
+
self._start_heartbeat()
|
|
71
|
+
|
|
72
|
+
# Register shutdown handlers (atexit + SIGTERM for containers)
|
|
73
|
+
atexit.register(self._atexit_handler)
|
|
74
|
+
try:
|
|
75
|
+
signal.signal(signal.SIGTERM, lambda *_: self._atexit_handler())
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def _try_patch_frameworks(self) -> None:
|
|
80
|
+
"""Auto-detect and patch supported frameworks and DB libraries."""
|
|
81
|
+
# FastAPI
|
|
82
|
+
try:
|
|
83
|
+
import fastapi # noqa: F401
|
|
84
|
+
|
|
85
|
+
from steadwing.integrations.fastapi import patch_fastapi
|
|
86
|
+
|
|
87
|
+
patch_fastapi(on_exception=self._handle_exception)
|
|
88
|
+
except ImportError:
|
|
89
|
+
pass
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# Django
|
|
94
|
+
try:
|
|
95
|
+
import django # noqa: F401
|
|
96
|
+
|
|
97
|
+
from steadwing.integrations.django import patch_django
|
|
98
|
+
|
|
99
|
+
patch_django(on_exception=self._handle_exception)
|
|
100
|
+
except ImportError:
|
|
101
|
+
pass
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Django DB (query breadcrumbs)
|
|
106
|
+
try:
|
|
107
|
+
from django.db.backends.utils import CursorWrapper # noqa: F401
|
|
108
|
+
|
|
109
|
+
from steadwing.integrations.django_db import patch_django_db
|
|
110
|
+
|
|
111
|
+
patch_django_db()
|
|
112
|
+
except ImportError:
|
|
113
|
+
pass
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Flask
|
|
118
|
+
try:
|
|
119
|
+
import flask # noqa: F401
|
|
120
|
+
|
|
121
|
+
from steadwing.integrations.flask import patch_flask
|
|
122
|
+
|
|
123
|
+
patch_flask(on_exception=self._handle_exception)
|
|
124
|
+
except ImportError:
|
|
125
|
+
pass
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# SQLAlchemy (query breadcrumbs)
|
|
130
|
+
try:
|
|
131
|
+
import sqlalchemy # noqa: F401
|
|
132
|
+
|
|
133
|
+
from steadwing.integrations.sqlalchemy import patch_sqlalchemy
|
|
134
|
+
|
|
135
|
+
patch_sqlalchemy()
|
|
136
|
+
except ImportError:
|
|
137
|
+
pass
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def _handle_exception(self, event_data: dict[str, Any], flush: bool = False) -> None:
|
|
142
|
+
"""Handle a captured exception event.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
event_data: The exception event payload.
|
|
146
|
+
flush: If True (uncaught/thread/async errors that kill the process),
|
|
147
|
+
flush the transport synchronously so the event isn't lost.
|
|
148
|
+
"""
|
|
149
|
+
if not self.enabled or self._transport is None:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
event = base_event("exception", self.service, self.env, self.runtime)
|
|
154
|
+
event.update(event_data)
|
|
155
|
+
self._transport.enqueue(event)
|
|
156
|
+
if flush:
|
|
157
|
+
self._transport.flush_sync()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def _handle_log_event(self, log_data: dict[str, Any]) -> None:
|
|
162
|
+
"""Handle a captured log event."""
|
|
163
|
+
if not self.enabled or self._transport is None:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
event = base_event("log", self.service, self.env, self.runtime)
|
|
168
|
+
event.update(log_data)
|
|
169
|
+
self._transport.enqueue(event)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
def _start_heartbeat(self) -> None:
|
|
174
|
+
"""Start the recurring heartbeat timer."""
|
|
175
|
+
if self._shutdown:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
def heartbeat() -> None:
|
|
179
|
+
if self._shutdown:
|
|
180
|
+
return
|
|
181
|
+
try:
|
|
182
|
+
event = base_event("heartbeat", self.service, self.env, self.runtime)
|
|
183
|
+
event["status"] = "healthy"
|
|
184
|
+
if self._transport is not None:
|
|
185
|
+
self._transport.enqueue(event)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
# Reschedule
|
|
189
|
+
if not self._shutdown:
|
|
190
|
+
self._heartbeat_timer = threading.Timer(_HEARTBEAT_INTERVAL, heartbeat)
|
|
191
|
+
self._heartbeat_timer.daemon = True
|
|
192
|
+
self._heartbeat_timer.start()
|
|
193
|
+
|
|
194
|
+
self._heartbeat_timer = threading.Timer(_HEARTBEAT_INTERVAL, heartbeat)
|
|
195
|
+
self._heartbeat_timer.daemon = True
|
|
196
|
+
self._heartbeat_timer.start()
|
|
197
|
+
|
|
198
|
+
def _atexit_handler(self) -> None:
|
|
199
|
+
"""Flush remaining events on process exit."""
|
|
200
|
+
self._shutdown = True
|
|
201
|
+
if self._heartbeat_timer is not None:
|
|
202
|
+
self._heartbeat_timer.cancel()
|
|
203
|
+
if self._transport is not None:
|
|
204
|
+
self._transport.shutdown()
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def get_instance(cls) -> SteadwingClient | None:
|
|
208
|
+
"""Get the singleton instance."""
|
|
209
|
+
return cls._instance
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def set_instance(cls, instance: SteadwingClient) -> None:
|
|
213
|
+
"""Set the singleton instance."""
|
|
214
|
+
with cls._init_lock:
|
|
215
|
+
cls._instance = instance
|
steadwing/hooks.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Exception hooks for sys.excepthook and threading patches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from steadwing.breadcrumbs import get_breadcrumbs
|
|
11
|
+
from steadwing.scrubber import scrub
|
|
12
|
+
|
|
13
|
+
_original_excepthook: Any = None
|
|
14
|
+
_original_thread_run: Any = None
|
|
15
|
+
_patched = False
|
|
16
|
+
_on_exception_callback: Any = None
|
|
17
|
+
|
|
18
|
+
MAX_LOCAL_VAR_LENGTH = 1024
|
|
19
|
+
MAX_STACK_FRAMES = 50
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extract_locals(tb: Any) -> list[dict[str, Any]]:
|
|
23
|
+
"""Extract local variables from traceback frames."""
|
|
24
|
+
frames = []
|
|
25
|
+
current = tb
|
|
26
|
+
while current is not None:
|
|
27
|
+
try:
|
|
28
|
+
frame = current.tb_frame
|
|
29
|
+
local_vars = {}
|
|
30
|
+
for key, value in frame.f_locals.items():
|
|
31
|
+
if key.startswith("__"):
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
val_repr = repr(value)
|
|
35
|
+
if len(val_repr) > MAX_LOCAL_VAR_LENGTH:
|
|
36
|
+
val_repr = val_repr[:MAX_LOCAL_VAR_LENGTH] + "..."
|
|
37
|
+
local_vars[key] = val_repr
|
|
38
|
+
except Exception:
|
|
39
|
+
local_vars[key] = "<unrepresentable>"
|
|
40
|
+
frames.append({
|
|
41
|
+
"filename": frame.f_code.co_filename,
|
|
42
|
+
"lineno": current.tb_lineno,
|
|
43
|
+
"function": frame.f_code.co_name,
|
|
44
|
+
"locals": scrub(local_vars),
|
|
45
|
+
})
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
current = current.tb_next
|
|
49
|
+
return frames
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_exception_chain(exc: BaseException) -> list[dict[str, Any]]:
|
|
53
|
+
"""Extract the exception chain (__cause__ and __context__)."""
|
|
54
|
+
chain = []
|
|
55
|
+
seen: set[int] = set()
|
|
56
|
+
current: BaseException | None = exc
|
|
57
|
+
|
|
58
|
+
while current is not None and id(current) not in seen:
|
|
59
|
+
seen.add(id(current))
|
|
60
|
+
chain.append({
|
|
61
|
+
"type": type(current).__name__,
|
|
62
|
+
"module": type(current).__module__,
|
|
63
|
+
"message": str(current),
|
|
64
|
+
})
|
|
65
|
+
# Follow __cause__ first, then __context__
|
|
66
|
+
if current.__cause__ is not None:
|
|
67
|
+
current = current.__cause__
|
|
68
|
+
elif current.__context__ is not None and not current.__suppress_context__:
|
|
69
|
+
current = current.__context__
|
|
70
|
+
else:
|
|
71
|
+
current = None
|
|
72
|
+
|
|
73
|
+
return chain
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_exception_event(
|
|
77
|
+
exc_type: type,
|
|
78
|
+
exc_value: BaseException,
|
|
79
|
+
exc_tb: Any,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Build an exception event payload."""
|
|
82
|
+
frames = _extract_locals(exc_tb) if exc_tb else []
|
|
83
|
+
if len(frames) > MAX_STACK_FRAMES:
|
|
84
|
+
frames = frames[-MAX_STACK_FRAMES:]
|
|
85
|
+
tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"exception_type": exc_type.__name__,
|
|
89
|
+
"exception_module": exc_type.__module__,
|
|
90
|
+
"exception_message": str(exc_value),
|
|
91
|
+
"traceback": tb_str,
|
|
92
|
+
"frames": frames,
|
|
93
|
+
"exception_chain": _extract_exception_chain(exc_value),
|
|
94
|
+
"breadcrumbs": get_breadcrumbs(),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _steadwing_excepthook(exc_type: type, exc_value: BaseException, exc_tb: Any) -> None:
|
|
99
|
+
"""Custom excepthook that captures exceptions then calls the original."""
|
|
100
|
+
try:
|
|
101
|
+
if _on_exception_callback is not None:
|
|
102
|
+
event_data = build_exception_event(exc_type, exc_value, exc_tb)
|
|
103
|
+
# Uncaught exception → process is about to die. Flush synchronously
|
|
104
|
+
# so startup/boot crashes still reach the backend.
|
|
105
|
+
_on_exception_callback(event_data, flush=True)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
# Always call the original excepthook
|
|
110
|
+
if _original_excepthook is not None:
|
|
111
|
+
_original_excepthook(exc_type, exc_value, exc_tb)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _patched_thread_run(self: threading.Thread) -> None:
|
|
115
|
+
"""Patched Thread.run that captures exceptions in threads."""
|
|
116
|
+
try:
|
|
117
|
+
if _original_thread_run is not None:
|
|
118
|
+
_original_thread_run(self)
|
|
119
|
+
except Exception:
|
|
120
|
+
try:
|
|
121
|
+
if _on_exception_callback is not None:
|
|
122
|
+
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
123
|
+
if exc_type is not None:
|
|
124
|
+
event_data = build_exception_event(exc_type, exc_value, exc_tb)
|
|
125
|
+
# Thread is dying from this exception — flush before re-raise.
|
|
126
|
+
_on_exception_callback(event_data, flush=True)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def patch_hooks(on_exception: Any) -> None:
|
|
133
|
+
"""Install exception hooks.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
on_exception: Callback function that receives exception event data dict.
|
|
137
|
+
"""
|
|
138
|
+
global _original_excepthook, _original_thread_run, _patched, _on_exception_callback
|
|
139
|
+
|
|
140
|
+
if _patched:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
_on_exception_callback = on_exception
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
_original_excepthook = sys.excepthook
|
|
147
|
+
sys.excepthook = _steadwing_excepthook
|
|
148
|
+
|
|
149
|
+
_original_thread_run = threading.Thread.run
|
|
150
|
+
threading.Thread.run = _patched_thread_run
|
|
151
|
+
|
|
152
|
+
_patched = True
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def unpatch_hooks() -> None:
|
|
158
|
+
"""Restore original exception hooks."""
|
|
159
|
+
global _original_excepthook, _original_thread_run, _patched, _on_exception_callback
|
|
160
|
+
|
|
161
|
+
if not _patched:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
if _original_excepthook is not None:
|
|
166
|
+
sys.excepthook = _original_excepthook
|
|
167
|
+
if _original_thread_run is not None:
|
|
168
|
+
threading.Thread.run = _original_thread_run
|
|
169
|
+
_patched = False
|
|
170
|
+
_on_exception_callback = None
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Steadwing framework integrations."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Asyncio integration: capture unhandled exceptions from tasks and the event loop.
|
|
2
|
+
|
|
3
|
+
Errors raised inside `async def` handlers, `create_task()`, `gather()`, and other
|
|
4
|
+
coroutines that are never awaited surface through the event loop's exception
|
|
5
|
+
handler rather than `sys.excepthook`. This module installs a handler on the
|
|
6
|
+
running loop (if any) and patches loop creation so future loops are covered too.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from steadwing.hooks import build_exception_event
|
|
15
|
+
|
|
16
|
+
_patched = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _make_handler(on_exception: Any, previous: Any) -> Any:
|
|
20
|
+
"""Wrap an event-loop exception handler so captured errors are forwarded."""
|
|
21
|
+
|
|
22
|
+
def handler(loop: Any, context: dict[str, Any]) -> None:
|
|
23
|
+
exc = context.get("exception")
|
|
24
|
+
if exc is not None:
|
|
25
|
+
try:
|
|
26
|
+
event_data = build_exception_event(type(exc), exc, exc.__traceback__)
|
|
27
|
+
# Unhandled async errors are fatal-ish — flush immediately.
|
|
28
|
+
on_exception(event_data, flush=True)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
# Preserve prior behavior (logging the error, etc.)
|
|
32
|
+
try:
|
|
33
|
+
if previous is not None:
|
|
34
|
+
previous(loop, context)
|
|
35
|
+
else:
|
|
36
|
+
loop.default_exception_handler(context)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
return handler
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def patch_asyncio(on_exception: Any) -> None:
|
|
44
|
+
"""Install an asyncio exception handler on the current and future event loops.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
on_exception: Callback ``(event_data: dict, flush: bool)`` invoked per error.
|
|
48
|
+
"""
|
|
49
|
+
global _patched
|
|
50
|
+
|
|
51
|
+
if _patched:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Cover a loop that is already running (init() called from async context).
|
|
56
|
+
try:
|
|
57
|
+
running = asyncio.get_running_loop()
|
|
58
|
+
running.set_exception_handler(_make_handler(on_exception, running.get_exception_handler()))
|
|
59
|
+
except RuntimeError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
# Cover loops created later (the common case: init() runs before the
|
|
63
|
+
# server starts its loop).
|
|
64
|
+
_original_new = asyncio.new_event_loop
|
|
65
|
+
|
|
66
|
+
def _patched_new() -> Any:
|
|
67
|
+
loop = _original_new()
|
|
68
|
+
try:
|
|
69
|
+
loop.set_exception_handler(_make_handler(on_exception, None))
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
return loop
|
|
73
|
+
|
|
74
|
+
asyncio.new_event_loop = _patched_new
|
|
75
|
+
|
|
76
|
+
_patched = True
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|