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