oluso 1.0.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.
- oluso/__init__.py +34 -0
- oluso/client.py +216 -0
- oluso/context.py +111 -0
- oluso/fingerprint.py +53 -0
- oluso/integrations/__init__.py +0 -0
- oluso/integrations/flask.py +151 -0
- oluso/options.py +42 -0
- oluso/queue.py +111 -0
- oluso/rate_limiter.py +36 -0
- oluso/sanitizer.py +100 -0
- oluso/server_context.py +34 -0
- oluso/transport.py +44 -0
- oluso/types.py +168 -0
- oluso-1.0.0.dist-info/METADATA +129 -0
- oluso-1.0.0.dist-info/RECORD +17 -0
- oluso-1.0.0.dist-info/WHEEL +4 -0
- oluso-1.0.0.dist-info/licenses/LICENSE +21 -0
oluso/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Oluso: AI-powered error monitoring for Python applications."""
|
|
2
|
+
|
|
3
|
+
from .client import Oluso
|
|
4
|
+
from .context import add_breadcrumb, scope, set_custom_context, set_user
|
|
5
|
+
from .options import Options
|
|
6
|
+
from .types import (
|
|
7
|
+
Breadcrumb,
|
|
8
|
+
BreadcrumbLevel,
|
|
9
|
+
ErrorContext,
|
|
10
|
+
ErrorReport,
|
|
11
|
+
RequestContext,
|
|
12
|
+
ServerContext,
|
|
13
|
+
Severity,
|
|
14
|
+
UserContext,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Oluso",
|
|
21
|
+
"Options",
|
|
22
|
+
"scope",
|
|
23
|
+
"add_breadcrumb",
|
|
24
|
+
"set_user",
|
|
25
|
+
"set_custom_context",
|
|
26
|
+
"Breadcrumb",
|
|
27
|
+
"BreadcrumbLevel",
|
|
28
|
+
"ErrorContext",
|
|
29
|
+
"ErrorReport",
|
|
30
|
+
"RequestContext",
|
|
31
|
+
"ServerContext",
|
|
32
|
+
"Severity",
|
|
33
|
+
"UserContext",
|
|
34
|
+
]
|
oluso/client.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Any, Dict, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from .context import _snapshot
|
|
11
|
+
from .fingerprint import generate_fingerprint
|
|
12
|
+
from .options import Options
|
|
13
|
+
from .queue import OfflineQueue
|
|
14
|
+
from .rate_limiter import RateLimiter
|
|
15
|
+
from .sanitizer import Sanitizer
|
|
16
|
+
from .server_context import get_server_context
|
|
17
|
+
from .transport import TransportError, send_error_report
|
|
18
|
+
from .types import ErrorContext, ErrorReport, RequestContext, Severity
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("oluso")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Oluso:
|
|
24
|
+
"""Reports errors to Oluso.
|
|
25
|
+
|
|
26
|
+
Sends run on a background daemon thread (tracked so `flush()` can drain
|
|
27
|
+
them before shutdown) rather than blocking the calling thread on network
|
|
28
|
+
I/O, since capture calls typically sit on a request-handling hot path.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, options: Options) -> None:
|
|
32
|
+
if not options.api_key:
|
|
33
|
+
raise ValueError("oluso: api_key is required")
|
|
34
|
+
self._options = options
|
|
35
|
+
self._sanitizer = Sanitizer(options.sensitive_keys)
|
|
36
|
+
self._rate_limiter = RateLimiter(options.max_errors_per_minute)
|
|
37
|
+
self._offline_queue = OfflineQueue(options.max_queue_size, options.queue_dir)
|
|
38
|
+
|
|
39
|
+
self._send_queue: "queue.Queue[Dict[str, Any]]" = queue.Queue()
|
|
40
|
+
self._pending = 0
|
|
41
|
+
self._pending_lock = threading.Lock()
|
|
42
|
+
self._all_sent = threading.Condition(self._pending_lock)
|
|
43
|
+
self._worker = threading.Thread(target=self._worker_loop, name="oluso-sender", daemon=True)
|
|
44
|
+
self._worker.start()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def max_breadcrumbs(self) -> int:
|
|
48
|
+
"""The configured max breadcrumbs per scope, for framework
|
|
49
|
+
integrations that open a scope themselves.
|
|
50
|
+
"""
|
|
51
|
+
return self._options.max_breadcrumbs
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def sanitizer(self) -> Sanitizer:
|
|
55
|
+
"""The Sanitizer configured for this client (respects
|
|
56
|
+
Options.sensitive_keys), for framework integrations that build a
|
|
57
|
+
RequestContext manually.
|
|
58
|
+
"""
|
|
59
|
+
return self._sanitizer
|
|
60
|
+
|
|
61
|
+
def capture_exception(
|
|
62
|
+
self, error: BaseException, custom_context: Optional[Dict[str, Any]] = None
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Report error, attaching any breadcrumbs/user/custom context on the
|
|
65
|
+
current scope (see `oluso.scope`), plus any custom_context given
|
|
66
|
+
here.
|
|
67
|
+
"""
|
|
68
|
+
self._capture(error, http_info=None, custom_context=custom_context)
|
|
69
|
+
|
|
70
|
+
def capture_http_error(
|
|
71
|
+
self,
|
|
72
|
+
error: BaseException,
|
|
73
|
+
request_context: RequestContext,
|
|
74
|
+
status_code: int,
|
|
75
|
+
custom_context: Optional[Dict[str, Any]] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Like capture_exception but also attaches HTTP request context and
|
|
78
|
+
a response status code, for reporting an error with request context
|
|
79
|
+
from a framework integration.
|
|
80
|
+
"""
|
|
81
|
+
self._capture(error, http_info=(request_context, status_code), custom_context=custom_context)
|
|
82
|
+
|
|
83
|
+
def _capture(
|
|
84
|
+
self,
|
|
85
|
+
error: BaseException,
|
|
86
|
+
http_info: Optional[Tuple[RequestContext, int]],
|
|
87
|
+
custom_context: Optional[Dict[str, Any]],
|
|
88
|
+
) -> None:
|
|
89
|
+
if error is None:
|
|
90
|
+
return
|
|
91
|
+
if self._options.should_report and not self._options.should_report(error):
|
|
92
|
+
return
|
|
93
|
+
if not self._rate_limiter.can_send():
|
|
94
|
+
if self._options.log_to_console:
|
|
95
|
+
logger.warning("[Oluso] rate limit exceeded, error not reported")
|
|
96
|
+
return
|
|
97
|
+
if self._options.log_to_console:
|
|
98
|
+
logger.error("[Oluso] %s", error)
|
|
99
|
+
|
|
100
|
+
stack_trace = "".join(
|
|
101
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
102
|
+
)
|
|
103
|
+
err_ctx = self._build_error_context(http_info, custom_context)
|
|
104
|
+
|
|
105
|
+
if self._options.fingerprint:
|
|
106
|
+
fingerprint = self._options.fingerprint(error, err_ctx)
|
|
107
|
+
else:
|
|
108
|
+
fingerprint = generate_fingerprint(error, stack_trace)
|
|
109
|
+
|
|
110
|
+
severity = self._options.default_severity
|
|
111
|
+
if http_info is not None:
|
|
112
|
+
_, status_code = http_info
|
|
113
|
+
if status_code >= 500:
|
|
114
|
+
severity = Severity.CRITICAL
|
|
115
|
+
elif status_code >= 400:
|
|
116
|
+
severity = Severity.HIGH
|
|
117
|
+
|
|
118
|
+
report = ErrorReport(
|
|
119
|
+
title=_error_title(error),
|
|
120
|
+
message=str(error),
|
|
121
|
+
stack_trace=stack_trace,
|
|
122
|
+
environment=self._options.environment,
|
|
123
|
+
severity=severity.value if isinstance(severity, Severity) else severity,
|
|
124
|
+
tags=list(self._options.tags),
|
|
125
|
+
fingerprint=fingerprint,
|
|
126
|
+
context=err_ctx,
|
|
127
|
+
timestamp=int(time.time() * 1000),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
with self._pending_lock:
|
|
131
|
+
self._pending += 1
|
|
132
|
+
self._send_queue.put(report.to_dict())
|
|
133
|
+
|
|
134
|
+
def _build_error_context(
|
|
135
|
+
self,
|
|
136
|
+
http_info: Optional[Tuple[RequestContext, int]],
|
|
137
|
+
custom_context: Optional[Dict[str, Any]],
|
|
138
|
+
) -> ErrorContext:
|
|
139
|
+
breadcrumbs, user, custom = _snapshot()
|
|
140
|
+
|
|
141
|
+
if custom_context:
|
|
142
|
+
custom = {**custom, **custom_context}
|
|
143
|
+
|
|
144
|
+
err_ctx = ErrorContext(
|
|
145
|
+
server=get_server_context(),
|
|
146
|
+
user=user,
|
|
147
|
+
custom=custom,
|
|
148
|
+
breadcrumbs=breadcrumbs,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if http_info is not None:
|
|
152
|
+
err_ctx.request = http_info[0]
|
|
153
|
+
|
|
154
|
+
return err_ctx
|
|
155
|
+
|
|
156
|
+
def _worker_loop(self) -> None:
|
|
157
|
+
while True:
|
|
158
|
+
report = self._send_queue.get()
|
|
159
|
+
try:
|
|
160
|
+
self._send_report(report)
|
|
161
|
+
finally:
|
|
162
|
+
with self._pending_lock:
|
|
163
|
+
self._pending -= 1
|
|
164
|
+
if self._pending <= 0:
|
|
165
|
+
self._all_sent.notify_all()
|
|
166
|
+
self._send_queue.task_done()
|
|
167
|
+
|
|
168
|
+
def _send_report(self, report: Dict[str, Any]) -> None:
|
|
169
|
+
try:
|
|
170
|
+
send_error_report(
|
|
171
|
+
self._options.endpoint, report, self._options.api_key, self._options.timeout
|
|
172
|
+
)
|
|
173
|
+
except TransportError as e:
|
|
174
|
+
if self._options.log_to_console:
|
|
175
|
+
logger.error("[Oluso] failed to send error report: %s", e)
|
|
176
|
+
if self._options.enable_offline_queue:
|
|
177
|
+
self._offline_queue.enqueue(report)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if self._options.enable_offline_queue and not self._offline_queue.is_empty():
|
|
181
|
+
self._offline_queue.process_queue(
|
|
182
|
+
lambda r: send_error_report(
|
|
183
|
+
self._options.endpoint, r, self._options.api_key, self._options.timeout
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def flush(self, timeout: Optional[float] = None) -> bool:
|
|
188
|
+
"""Block until all in-flight and queued reports have been sent, or
|
|
189
|
+
timeout elapses (None waits indefinitely). Returns True if everything
|
|
190
|
+
was flushed before the timeout. Call this before your process exits
|
|
191
|
+
so a capture right before shutdown isn't lost.
|
|
192
|
+
"""
|
|
193
|
+
with self._pending_lock:
|
|
194
|
+
deadline = None if timeout is None else time.time() + timeout
|
|
195
|
+
while self._pending > 0:
|
|
196
|
+
remaining = None if deadline is None else max(0.0, deadline - time.time())
|
|
197
|
+
if remaining == 0.0:
|
|
198
|
+
return False
|
|
199
|
+
if not self._all_sent.wait(remaining):
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
if self._options.enable_offline_queue and not self._offline_queue.is_empty():
|
|
203
|
+
self._offline_queue.process_queue(
|
|
204
|
+
lambda r: send_error_report(
|
|
205
|
+
self._options.endpoint, r, self._options.api_key, self._options.timeout
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _error_title(error: BaseException) -> str:
|
|
212
|
+
message = str(error)
|
|
213
|
+
first_line = message.splitlines()[0].strip() if message else ""
|
|
214
|
+
if first_line:
|
|
215
|
+
return first_line if len(first_line) <= 100 else first_line[:97] + "..."
|
|
216
|
+
return f"{type(error).__name__} error"
|
oluso/context.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from .types import Breadcrumb, BreadcrumbLevel, UserContext
|
|
10
|
+
|
|
11
|
+
_current_scope: "contextvars.ContextVar[Optional[_Scope]]" = contextvars.ContextVar(
|
|
12
|
+
"oluso_scope", default=None
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Scope:
|
|
17
|
+
def __init__(self, max_breadcrumbs: int) -> None:
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
self._max_breadcrumbs = max_breadcrumbs
|
|
20
|
+
self.breadcrumbs: List[Breadcrumb] = []
|
|
21
|
+
self.user: Optional[UserContext] = None
|
|
22
|
+
self.custom: Dict[str, Any] = {}
|
|
23
|
+
self.request_start: Optional[float] = None
|
|
24
|
+
|
|
25
|
+
def add_breadcrumb(self, breadcrumb: Breadcrumb) -> None:
|
|
26
|
+
with self._lock:
|
|
27
|
+
breadcrumb.timestamp = time.time()
|
|
28
|
+
self.breadcrumbs.append(breadcrumb)
|
|
29
|
+
if len(self.breadcrumbs) > self._max_breadcrumbs:
|
|
30
|
+
self.breadcrumbs.pop(0)
|
|
31
|
+
|
|
32
|
+
def set_user(self, user: UserContext) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self.user = user
|
|
35
|
+
|
|
36
|
+
def set_custom(self, key: str, value: Any) -> None:
|
|
37
|
+
with self._lock:
|
|
38
|
+
self.custom[key] = value
|
|
39
|
+
|
|
40
|
+
def snapshot(self) -> Tuple[List[Breadcrumb], Optional[UserContext], Dict[str, Any]]:
|
|
41
|
+
with self._lock:
|
|
42
|
+
return list(self.breadcrumbs), self.user, dict(self.custom)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def scope(max_breadcrumbs: int = 30) -> Iterator[None]:
|
|
47
|
+
"""Context manager establishing an isolated breadcrumb/user/custom-data
|
|
48
|
+
scope for the code inside it -- Python's equivalent of the per-request
|
|
49
|
+
scope other Oluso SDKs build on Node's AsyncLocalStorage or Go's
|
|
50
|
+
context.Context, implemented with contextvars instead, since that's the
|
|
51
|
+
idiomatic mechanism for request/task-scoped state in Python (correctly
|
|
52
|
+
isolated per thread AND per asyncio task).
|
|
53
|
+
|
|
54
|
+
Framework integrations call this for you; call it yourself for
|
|
55
|
+
non-request work (a background job, a CLI command) where you still want
|
|
56
|
+
breadcrumbs/user context scoped to that one unit of work::
|
|
57
|
+
|
|
58
|
+
with oluso.scope():
|
|
59
|
+
oluso.add_breadcrumb("job started")
|
|
60
|
+
client.capture_exception(err)
|
|
61
|
+
"""
|
|
62
|
+
token = _current_scope.set(_Scope(max_breadcrumbs))
|
|
63
|
+
try:
|
|
64
|
+
yield
|
|
65
|
+
finally:
|
|
66
|
+
_current_scope.reset(token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def add_breadcrumb(
|
|
70
|
+
message: str,
|
|
71
|
+
level: BreadcrumbLevel = BreadcrumbLevel.INFO,
|
|
72
|
+
category: Optional[str] = None,
|
|
73
|
+
data: Optional[Dict[str, Any]] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Record a breadcrumb on the current scope. A no-op outside a scope()."""
|
|
76
|
+
s = _current_scope.get()
|
|
77
|
+
if s is None:
|
|
78
|
+
return
|
|
79
|
+
s.add_breadcrumb(Breadcrumb(message=message, level=level, category=category, data=data))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_user(user: UserContext) -> None:
|
|
83
|
+
"""Set the user context on the current scope. A no-op outside a scope()."""
|
|
84
|
+
s = _current_scope.get()
|
|
85
|
+
if s is not None:
|
|
86
|
+
s.set_user(user)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def set_custom_context(key: str, value: Any) -> None:
|
|
90
|
+
"""Set a custom key/value on the current scope. A no-op outside a scope()."""
|
|
91
|
+
s = _current_scope.get()
|
|
92
|
+
if s is not None:
|
|
93
|
+
s.set_custom(key, value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _set_request_start_time(t: float) -> None:
|
|
97
|
+
s = _current_scope.get()
|
|
98
|
+
if s is not None:
|
|
99
|
+
s.request_start = t
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_request_start_time() -> Optional[float]:
|
|
103
|
+
s = _current_scope.get()
|
|
104
|
+
return s.request_start if s is not None else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _snapshot() -> Tuple[List[Breadcrumb], Optional[UserContext], Dict[str, Any]]:
|
|
108
|
+
s = _current_scope.get()
|
|
109
|
+
if s is None:
|
|
110
|
+
return [], None, {}
|
|
111
|
+
return s.snapshot()
|
oluso/fingerprint.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
_RE_NUMBER = re.compile(r"\d+")
|
|
8
|
+
_RE_UUID = re.compile(
|
|
9
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE
|
|
10
|
+
)
|
|
11
|
+
_RE_PATH = re.compile(r"/[\w/.\-]+")
|
|
12
|
+
_RE_URL = re.compile(r"https?://\S+")
|
|
13
|
+
_RE_SPACE = re.compile(r"\s+")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_fingerprint(error: BaseException, stack_trace: Optional[str] = None) -> str:
|
|
17
|
+
"""Produce a stable identifier for grouping similar errors together, from
|
|
18
|
+
the error's type, a normalized version of its message (with dynamic
|
|
19
|
+
values like IDs and paths stripped), and -- if provided -- a stack trace
|
|
20
|
+
signature.
|
|
21
|
+
"""
|
|
22
|
+
components = [type(error).__name__, _normalize_message(str(error))]
|
|
23
|
+
if stack_trace:
|
|
24
|
+
components.append(_stack_signature(stack_trace))
|
|
25
|
+
|
|
26
|
+
digest = hashlib.sha256("|".join(components).encode("utf-8")).hexdigest()
|
|
27
|
+
return digest[:8]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_message(message: str) -> str:
|
|
31
|
+
message = _RE_NUMBER.sub("N", message)
|
|
32
|
+
message = _RE_UUID.sub("UUID", message)
|
|
33
|
+
message = _RE_PATH.sub("PATH", message)
|
|
34
|
+
message = _RE_URL.sub("URL", message)
|
|
35
|
+
message = _RE_SPACE.sub(" ", message)
|
|
36
|
+
return message.strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _stack_signature(stack: str, limit: int = 6) -> str:
|
|
40
|
+
"""Extract just the call-site lines from a traceback (skipping the
|
|
41
|
+
indented "File ..., line N" detail lines Python's traceback module
|
|
42
|
+
produces), so the signature groups the same logical call site together
|
|
43
|
+
even as line numbers shift.
|
|
44
|
+
"""
|
|
45
|
+
frames = []
|
|
46
|
+
for line in stack.splitlines():
|
|
47
|
+
stripped = line.strip()
|
|
48
|
+
if not stripped or stripped.startswith("File ") or stripped.startswith("Traceback"):
|
|
49
|
+
continue
|
|
50
|
+
frames.append(stripped)
|
|
51
|
+
if len(frames) >= limit:
|
|
52
|
+
break
|
|
53
|
+
return "->".join(frames)
|
|
File without changes
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Oluso integration for Flask (and any other WSGI application).
|
|
2
|
+
|
|
3
|
+
Two hooks are combined here, deliberately:
|
|
4
|
+
|
|
5
|
+
- A WSGI middleware wrapping the whole app, since that gives one
|
|
6
|
+
synchronous call frame per request -- the natural place to open a single
|
|
7
|
+
`oluso.scope()` context manager around it, and to catch 5xx responses that
|
|
8
|
+
don't come from a raised exception (e.g. a view returning `"", 500`
|
|
9
|
+
directly). As a side effect, this works for any WSGI app, not just Flask.
|
|
10
|
+
- Flask's `got_request_exception` signal, since Flask/Werkzeug catches an
|
|
11
|
+
unhandled view exception *internally* and converts it to a 500 response
|
|
12
|
+
before it would ever reach the outer WSGI middleware's `except` clause --
|
|
13
|
+
without this signal, only a synthetic "server error: 500" message would
|
|
14
|
+
be reported instead of the real exception.
|
|
15
|
+
|
|
16
|
+
Both paths mark the request as already-reported (via the WSGI environ, which
|
|
17
|
+
both share) so a raised exception is never double-reported.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
24
|
+
from urllib.parse import parse_qs
|
|
25
|
+
|
|
26
|
+
from ..client import Oluso
|
|
27
|
+
from ..context import _get_request_start_time, _set_request_start_time, add_breadcrumb, scope
|
|
28
|
+
from ..types import BreadcrumbLevel, RequestContext
|
|
29
|
+
|
|
30
|
+
StartResponse = Callable[..., Any]
|
|
31
|
+
WSGIApp = Callable[[Dict[str, Any], StartResponse], Iterable[bytes]]
|
|
32
|
+
|
|
33
|
+
_REPORTED_KEY = "oluso.reported"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OlusoMiddleware:
|
|
37
|
+
"""WSGI middleware: scopes breadcrumbs to each request and auto-reports
|
|
38
|
+
5xx responses that aren't already covered by the `got_request_exception`
|
|
39
|
+
signal (see module docstring). Lets exceptions propagate normally
|
|
40
|
+
afterward -- this only observes and reports, it doesn't change how your
|
|
41
|
+
app responds to errors.
|
|
42
|
+
|
|
43
|
+
Note: status is captured from the synchronous `start_response` call, so
|
|
44
|
+
a view that defers setting its status code until a streamed response
|
|
45
|
+
body is iterated (uncommon in typical Flask apps) won't be captured
|
|
46
|
+
accurately.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, wsgi_app: WSGIApp, client: Oluso) -> None:
|
|
50
|
+
self.wsgi_app = wsgi_app
|
|
51
|
+
self.client = client
|
|
52
|
+
|
|
53
|
+
def __call__(self, environ: Dict[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
|
54
|
+
with scope(self.client.max_breadcrumbs):
|
|
55
|
+
_set_request_start_time(time.time())
|
|
56
|
+
method = environ.get("REQUEST_METHOD", "")
|
|
57
|
+
path = environ.get("PATH_INFO", "")
|
|
58
|
+
|
|
59
|
+
add_breadcrumb(
|
|
60
|
+
message=f"{method} {path}",
|
|
61
|
+
level=BreadcrumbLevel.INFO,
|
|
62
|
+
category="http",
|
|
63
|
+
data={"method": method, "url": path},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
status_holder: Dict[str, int] = {}
|
|
67
|
+
|
|
68
|
+
def _start_response(status: str, headers: List[Tuple[str, str]], exc_info: Any = None) -> Any:
|
|
69
|
+
status_holder["code"] = _parse_status_code(status)
|
|
70
|
+
return start_response(status, headers, exc_info)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
result = self.wsgi_app(environ, _start_response)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
if not environ.get(_REPORTED_KEY):
|
|
76
|
+
req_ctx = _build_request_context(self.client, environ)
|
|
77
|
+
self.client.capture_http_error(exc, req_ctx, 500)
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
status = status_holder.get("code", 200)
|
|
81
|
+
if status >= 500 and not environ.get(_REPORTED_KEY):
|
|
82
|
+
req_ctx = _build_request_context(self.client, environ)
|
|
83
|
+
error = RuntimeError(f"server error: {status} - {method} {path}")
|
|
84
|
+
self.client.capture_http_error(error, req_ctx, status)
|
|
85
|
+
|
|
86
|
+
add_breadcrumb(
|
|
87
|
+
message=f"Response {status} - {method} {path}",
|
|
88
|
+
level=BreadcrumbLevel.ERROR if status >= 400 else BreadcrumbLevel.INFO,
|
|
89
|
+
category="http",
|
|
90
|
+
data={"statusCode": status},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def init_app(app: Any, client: Oluso) -> None:
|
|
97
|
+
"""Register Oluso on a Flask app: `oluso.integrations.flask.init_app(app, client)`."""
|
|
98
|
+
app.wsgi_app = OlusoMiddleware(app.wsgi_app, client)
|
|
99
|
+
|
|
100
|
+
from flask import got_request_exception # local import: flask is an optional dependency
|
|
101
|
+
|
|
102
|
+
def _on_exception(sender: Any, exception: BaseException, **extra: Any) -> None:
|
|
103
|
+
from flask import request
|
|
104
|
+
|
|
105
|
+
request.environ[_REPORTED_KEY] = True
|
|
106
|
+
req_ctx = _build_request_context(client, request.environ)
|
|
107
|
+
client.capture_http_error(exception, req_ctx, 500)
|
|
108
|
+
|
|
109
|
+
got_request_exception.connect(_on_exception, app, weak=False)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_status_code(status: str) -> int:
|
|
113
|
+
try:
|
|
114
|
+
return int(status.split(" ", 1)[0])
|
|
115
|
+
except (ValueError, IndexError):
|
|
116
|
+
return 200
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_request_context(client: Oluso, environ: Dict[str, Any]) -> RequestContext:
|
|
120
|
+
method = environ.get("REQUEST_METHOD", "")
|
|
121
|
+
path = environ.get("PATH_INFO", "")
|
|
122
|
+
query_string = environ.get("QUERY_STRING", "")
|
|
123
|
+
|
|
124
|
+
headers: Dict[str, str] = {}
|
|
125
|
+
for key, value in environ.items():
|
|
126
|
+
if key.startswith("HTTP_"):
|
|
127
|
+
headers[key[5:].replace("_", "-").title()] = value
|
|
128
|
+
if environ.get("CONTENT_TYPE"):
|
|
129
|
+
headers["Content-Type"] = environ["CONTENT_TYPE"]
|
|
130
|
+
if environ.get("CONTENT_LENGTH"):
|
|
131
|
+
headers["Content-Length"] = environ["CONTENT_LENGTH"]
|
|
132
|
+
|
|
133
|
+
query: Dict[str, Any] = {}
|
|
134
|
+
if query_string:
|
|
135
|
+
for key, values in parse_qs(query_string).items():
|
|
136
|
+
query[key] = values[0] if len(values) == 1 else values
|
|
137
|
+
|
|
138
|
+
response_time_ms: Optional[int] = None
|
|
139
|
+
start = _get_request_start_time()
|
|
140
|
+
if start is not None:
|
|
141
|
+
response_time_ms = int((time.time() - start) * 1000)
|
|
142
|
+
|
|
143
|
+
return RequestContext(
|
|
144
|
+
url=path,
|
|
145
|
+
method=method,
|
|
146
|
+
headers=client.sanitizer.sanitize_headers(headers),
|
|
147
|
+
query=client.sanitizer.sanitize_query(query),
|
|
148
|
+
ip=environ.get("REMOTE_ADDR"),
|
|
149
|
+
user_agent=environ.get("HTTP_USER_AGENT"),
|
|
150
|
+
response_time_ms=response_time_ms,
|
|
151
|
+
)
|
oluso/options.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from .types import FingerprintFunc, Severity, ShouldReportFunc
|
|
7
|
+
|
|
8
|
+
DEFAULT_ENDPOINT = "https://api.oluso.dev/api/v1/error/report"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Options:
|
|
13
|
+
"""Configures an :class:`~oluso.client.Oluso` client."""
|
|
14
|
+
|
|
15
|
+
# API key for authentication (sent as the x-oluso-signature header). Required.
|
|
16
|
+
api_key: str
|
|
17
|
+
|
|
18
|
+
# Override the ingestion endpoint. Useful for self-hosting.
|
|
19
|
+
endpoint: str = DEFAULT_ENDPOINT
|
|
20
|
+
|
|
21
|
+
environment: str = "production"
|
|
22
|
+
default_severity: Severity = Severity.MEDIUM
|
|
23
|
+
tags: List[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
should_report: Optional[ShouldReportFunc] = None
|
|
26
|
+
fingerprint: Optional[FingerprintFunc] = None
|
|
27
|
+
|
|
28
|
+
# Timeout in seconds for each report request.
|
|
29
|
+
timeout: float = 5.0
|
|
30
|
+
|
|
31
|
+
log_to_console: bool = True
|
|
32
|
+
|
|
33
|
+
max_breadcrumbs: int = 30
|
|
34
|
+
|
|
35
|
+
enable_offline_queue: bool = True
|
|
36
|
+
max_queue_size: int = 100
|
|
37
|
+
# Override where the offline queue is persisted. Defaults to
|
|
38
|
+
# <tempdir>/oluso-queue.
|
|
39
|
+
queue_dir: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
max_errors_per_minute: int = 60
|
|
42
|
+
sensitive_keys: List[str] = field(default_factory=list)
|
oluso/queue.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class _QueuedReport:
|
|
14
|
+
report: Dict[str, Any]
|
|
15
|
+
timestamp: float
|
|
16
|
+
retries: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OfflineQueue:
|
|
20
|
+
"""Persists error reports that failed to send, for retry on the next
|
|
21
|
+
successful send.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, max_size: int = 100, queue_dir: Optional[str] = None) -> None:
|
|
25
|
+
self._max_size = max_size if max_size > 0 else 100
|
|
26
|
+
directory = queue_dir or os.path.join(tempfile.gettempdir(), "oluso-queue")
|
|
27
|
+
os.makedirs(directory, exist_ok=True)
|
|
28
|
+
self._file_path = os.path.join(directory, "error-queue.json")
|
|
29
|
+
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._queue: List[_QueuedReport] = []
|
|
32
|
+
self._load()
|
|
33
|
+
|
|
34
|
+
def enqueue(self, report: Dict[str, Any]) -> None:
|
|
35
|
+
"""report is an already-serialized report (see ErrorReport.to_dict())."""
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._queue.append(_QueuedReport(report=report, timestamp=time.time()))
|
|
38
|
+
if len(self._queue) > self._max_size:
|
|
39
|
+
self._queue.pop(0)
|
|
40
|
+
self._save()
|
|
41
|
+
|
|
42
|
+
def size(self) -> int:
|
|
43
|
+
with self._lock:
|
|
44
|
+
return len(self._queue)
|
|
45
|
+
|
|
46
|
+
def is_empty(self) -> bool:
|
|
47
|
+
return self.size() == 0
|
|
48
|
+
|
|
49
|
+
def clear(self) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._queue = []
|
|
52
|
+
self._save()
|
|
53
|
+
|
|
54
|
+
def _load(self) -> None:
|
|
55
|
+
try:
|
|
56
|
+
with open(self._file_path, "r", encoding="utf-8") as f:
|
|
57
|
+
raw = json.load(f)
|
|
58
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
cutoff = time.time() - 24 * 60 * 60
|
|
62
|
+
self._queue = [
|
|
63
|
+
_QueuedReport(report=item["report"], timestamp=item["timestamp"], retries=item.get("retries", 0))
|
|
64
|
+
for item in raw
|
|
65
|
+
if item.get("timestamp", 0) > cutoff
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def _save(self) -> None:
|
|
69
|
+
"""Must be called with self._lock held."""
|
|
70
|
+
try:
|
|
71
|
+
payload = [
|
|
72
|
+
{"report": q.report, "timestamp": q.timestamp, "retries": q.retries} for q in self._queue
|
|
73
|
+
]
|
|
74
|
+
with open(self._file_path, "w", encoding="utf-8") as f:
|
|
75
|
+
json.dump(payload, f)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def process_queue(self, send_fn: Callable[[dict], None]) -> None:
|
|
80
|
+
"""Attempt to send each queued report in order via send_fn (raises on
|
|
81
|
+
failure), stopping at the first failure (which is requeued at the
|
|
82
|
+
front with an incremented retry count, and dropped after 3 failed
|
|
83
|
+
attempts).
|
|
84
|
+
|
|
85
|
+
send_fn is called without holding the queue's lock, so enqueue() from
|
|
86
|
+
another thread isn't blocked for the duration of a slow or hanging
|
|
87
|
+
send.
|
|
88
|
+
"""
|
|
89
|
+
while True:
|
|
90
|
+
with self._lock:
|
|
91
|
+
if not self._queue:
|
|
92
|
+
return
|
|
93
|
+
item = self._queue[0]
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
send_fn(item.report)
|
|
97
|
+
except Exception:
|
|
98
|
+
with self._lock:
|
|
99
|
+
if not self._queue or self._queue[0] is not item:
|
|
100
|
+
return # queue mutated concurrently; bail out safely
|
|
101
|
+
item.retries += 1
|
|
102
|
+
if item.retries >= 3:
|
|
103
|
+
self._queue.pop(0)
|
|
104
|
+
self._save()
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
with self._lock:
|
|
108
|
+
if not self._queue or self._queue[0] is not item:
|
|
109
|
+
return
|
|
110
|
+
self._queue.pop(0)
|
|
111
|
+
self._save()
|
oluso/rate_limiter.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RateLimiter:
|
|
9
|
+
"""Caps how many errors are reported within a rolling one-minute window."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, max_per_minute: int = 60, now: Callable[[], float] = time.time) -> None:
|
|
12
|
+
self._max_per_minute = max_per_minute if max_per_minute > 0 else 60
|
|
13
|
+
self._now = now
|
|
14
|
+
self._timestamps: List[float] = []
|
|
15
|
+
self._lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def can_send(self) -> bool:
|
|
18
|
+
with self._lock:
|
|
19
|
+
now = self._now()
|
|
20
|
+
cutoff = now - 60
|
|
21
|
+
self._timestamps = [ts for ts in self._timestamps if ts > cutoff]
|
|
22
|
+
|
|
23
|
+
if len(self._timestamps) < self._max_per_minute:
|
|
24
|
+
self._timestamps.append(now)
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
def count(self) -> int:
|
|
29
|
+
with self._lock:
|
|
30
|
+
cutoff = self._now() - 60
|
|
31
|
+
self._timestamps = [ts for ts in self._timestamps if ts > cutoff]
|
|
32
|
+
return len(self._timestamps)
|
|
33
|
+
|
|
34
|
+
def reset(self) -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._timestamps = []
|
oluso/sanitizer.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, Iterable, Optional
|
|
5
|
+
|
|
6
|
+
DEFAULT_SENSITIVE_KEYS = [
|
|
7
|
+
"password",
|
|
8
|
+
"passwd",
|
|
9
|
+
"pwd",
|
|
10
|
+
"secret",
|
|
11
|
+
"token",
|
|
12
|
+
"api_key",
|
|
13
|
+
"apikey",
|
|
14
|
+
"access_token",
|
|
15
|
+
"auth",
|
|
16
|
+
"credentials",
|
|
17
|
+
"mysql_pwd",
|
|
18
|
+
"private_key",
|
|
19
|
+
"privatekey",
|
|
20
|
+
"session",
|
|
21
|
+
"cookie",
|
|
22
|
+
"csrf",
|
|
23
|
+
"xsrf",
|
|
24
|
+
"authorization",
|
|
25
|
+
"bearer",
|
|
26
|
+
"jwt",
|
|
27
|
+
"ssn",
|
|
28
|
+
"social_security",
|
|
29
|
+
"credit_card",
|
|
30
|
+
"card_number",
|
|
31
|
+
"cvv",
|
|
32
|
+
"pin",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
REDACTED = "[REDACTED]"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Sanitizer:
|
|
39
|
+
"""Redacts sensitive values from request data before it's reported."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, custom_sensitive_keys: Optional[Iterable[str]] = None) -> None:
|
|
42
|
+
keys = list(DEFAULT_SENSITIVE_KEYS) + list(custom_sensitive_keys or [])
|
|
43
|
+
self._patterns = [re.compile(re.escape(k), re.IGNORECASE) for k in keys]
|
|
44
|
+
|
|
45
|
+
def _is_sensitive_key(self, key: str) -> bool:
|
|
46
|
+
return any(p.search(key) for p in self._patterns)
|
|
47
|
+
|
|
48
|
+
def sanitize_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
|
|
49
|
+
out: Dict[str, str] = {}
|
|
50
|
+
for key, value in headers.items():
|
|
51
|
+
lower = key.lower()
|
|
52
|
+
if lower in ("authorization", "cookie") or self._is_sensitive_key(key):
|
|
53
|
+
out[key] = REDACTED
|
|
54
|
+
else:
|
|
55
|
+
out[key] = str(value)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
def sanitize_query(self, query: Dict[str, Any]) -> Dict[str, str]:
|
|
59
|
+
out: Dict[str, str] = {}
|
|
60
|
+
for key, value in query.items():
|
|
61
|
+
if self._is_sensitive_key(key):
|
|
62
|
+
out[key] = REDACTED
|
|
63
|
+
elif isinstance(value, (list, tuple)):
|
|
64
|
+
out[key] = ", ".join(str(v) for v in value)
|
|
65
|
+
else:
|
|
66
|
+
out[key] = str(value)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
def sanitize_value(self, value: Any, max_depth: int = 10) -> Any:
|
|
70
|
+
"""Recursively redact sensitive keys from arbitrary JSON-shaped data
|
|
71
|
+
(dicts, lists, and primitives). Data of this shape can't contain
|
|
72
|
+
cycles, so unlike sanitizing an arbitrary object graph, no
|
|
73
|
+
circular-reference guard is needed.
|
|
74
|
+
"""
|
|
75
|
+
return self._sanitize(value, max_depth)
|
|
76
|
+
|
|
77
|
+
def _sanitize(self, value: Any, depth: int) -> Any:
|
|
78
|
+
if depth <= 0:
|
|
79
|
+
return "[Max Depth Reached]"
|
|
80
|
+
|
|
81
|
+
if isinstance(value, dict):
|
|
82
|
+
out: Dict[str, Any] = {}
|
|
83
|
+
for key, val in value.items():
|
|
84
|
+
if self._is_sensitive_key(str(key)):
|
|
85
|
+
out[key] = REDACTED
|
|
86
|
+
else:
|
|
87
|
+
out[key] = self._sanitize(val, depth - 1)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
if isinstance(value, (list, tuple)):
|
|
91
|
+
return [self._sanitize(item, depth - 1) for item in value]
|
|
92
|
+
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def truncate_string(s: str, max_length: int = 1000) -> str:
|
|
97
|
+
"""Limit a string's length to prevent huge payloads."""
|
|
98
|
+
if len(s) <= max_length:
|
|
99
|
+
return s
|
|
100
|
+
return s[:max_length] + "... [truncated]"
|
oluso/server_context.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .types import ServerContext
|
|
9
|
+
|
|
10
|
+
_process_start = time.time()
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import resource
|
|
14
|
+
|
|
15
|
+
def _memory_rss_bytes() -> int:
|
|
16
|
+
# ru_maxrss is KB on Linux, bytes on macOS/BSD.
|
|
17
|
+
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
18
|
+
return rss * 1024 if platform.system() != "Darwin" else rss
|
|
19
|
+
except ImportError: # resource is POSIX-only; not available on Windows
|
|
20
|
+
|
|
21
|
+
def _memory_rss_bytes() -> int:
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_server_context() -> ServerContext:
|
|
26
|
+
return ServerContext(
|
|
27
|
+
hostname=platform.node(),
|
|
28
|
+
platform=f"{platform.system()} {platform.release()}",
|
|
29
|
+
python_version=platform.python_version(),
|
|
30
|
+
process_id=os.getpid(),
|
|
31
|
+
memory_rss=_memory_rss_bytes(),
|
|
32
|
+
thread_count=threading.active_count(),
|
|
33
|
+
uptime_seconds=time.time() - _process_start,
|
|
34
|
+
)
|
oluso/transport.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TransportError(Exception):
|
|
10
|
+
"""Raised when a report fails to send. Carries no special data -- callers
|
|
11
|
+
only need to know it failed, so it can be queued for retry.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def send_error_report(
|
|
16
|
+
endpoint: str, report: Dict[str, Any], api_key: str, timeout: float
|
|
17
|
+
) -> None:
|
|
18
|
+
"""POST report to endpoint. Raises TransportError on any failure
|
|
19
|
+
(network error, timeout, or non-2xx status) so callers can decide how to
|
|
20
|
+
handle it (e.g. enqueue for retry) -- this never fails silently the way
|
|
21
|
+
a fire-and-forget call would.
|
|
22
|
+
"""
|
|
23
|
+
body = json.dumps(report).encode("utf-8")
|
|
24
|
+
req = urllib.request.Request(
|
|
25
|
+
endpoint,
|
|
26
|
+
data=body,
|
|
27
|
+
method="POST",
|
|
28
|
+
headers={
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
"x-oluso-signature": api_key,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
36
|
+
status = resp.status
|
|
37
|
+
if status < 200 or status >= 300:
|
|
38
|
+
raise TransportError(f"oluso: reporting failed with status {status}")
|
|
39
|
+
except urllib.error.HTTPError as e:
|
|
40
|
+
raise TransportError(f"oluso: reporting failed with status {e.code}") from e
|
|
41
|
+
except urllib.error.URLError as e:
|
|
42
|
+
raise TransportError(f"oluso: send report: {e.reason}") from e
|
|
43
|
+
except TimeoutError as e:
|
|
44
|
+
raise TransportError(f"oluso: send report timed out: {e}") from e
|
oluso/types.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Data types for Oluso error reports.
|
|
2
|
+
|
|
3
|
+
These serialize to the same wire format the Node and Go SDKs use (camelCase
|
|
4
|
+
JSON keys, with the historical exception of ``stack_trace``), since all
|
|
5
|
+
Oluso SDKs report to the same backend API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Callable, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Severity(str, Enum):
|
|
16
|
+
CRITICAL = "critical"
|
|
17
|
+
HIGH = "high"
|
|
18
|
+
MEDIUM = "medium"
|
|
19
|
+
LOW = "low"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BreadcrumbLevel(str, Enum):
|
|
23
|
+
DEBUG = "debug"
|
|
24
|
+
INFO = "info"
|
|
25
|
+
WARNING = "warning"
|
|
26
|
+
ERROR = "error"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _omit_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Breadcrumb:
|
|
35
|
+
message: str
|
|
36
|
+
level: BreadcrumbLevel = BreadcrumbLevel.INFO
|
|
37
|
+
category: Optional[str] = None
|
|
38
|
+
data: Optional[dict[str, Any]] = None
|
|
39
|
+
timestamp: Optional[float] = None # unix seconds, set when recorded
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
return _omit_none(
|
|
43
|
+
{
|
|
44
|
+
"timestamp": int(self.timestamp * 1000) if self.timestamp else None,
|
|
45
|
+
"message": self.message,
|
|
46
|
+
"level": self.level.value if isinstance(self.level, BreadcrumbLevel) else self.level,
|
|
47
|
+
"category": self.category,
|
|
48
|
+
"data": self.data,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class UserContext:
|
|
55
|
+
id: Optional[str] = None
|
|
56
|
+
email: Optional[str] = None
|
|
57
|
+
username: Optional[str] = None
|
|
58
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
return _omit_none(
|
|
62
|
+
{
|
|
63
|
+
"id": self.id,
|
|
64
|
+
"email": self.email,
|
|
65
|
+
"username": self.username,
|
|
66
|
+
"extra": self.extra or None,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class RequestContext:
|
|
73
|
+
url: str
|
|
74
|
+
method: str
|
|
75
|
+
headers: Optional[dict[str, str]] = None
|
|
76
|
+
query: Optional[dict[str, str]] = None
|
|
77
|
+
body: Any = None
|
|
78
|
+
ip: Optional[str] = None
|
|
79
|
+
user_agent: Optional[str] = None
|
|
80
|
+
response_time_ms: Optional[int] = None
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, Any]:
|
|
83
|
+
return _omit_none(
|
|
84
|
+
{
|
|
85
|
+
"url": self.url,
|
|
86
|
+
"method": self.method,
|
|
87
|
+
"headers": self.headers,
|
|
88
|
+
"query": self.query,
|
|
89
|
+
"body": self.body,
|
|
90
|
+
"ip": self.ip,
|
|
91
|
+
"userAgent": self.user_agent,
|
|
92
|
+
"responseTime": self.response_time_ms,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ServerContext:
|
|
99
|
+
hostname: str
|
|
100
|
+
platform: str
|
|
101
|
+
python_version: str
|
|
102
|
+
process_id: int
|
|
103
|
+
memory_rss: int
|
|
104
|
+
thread_count: int
|
|
105
|
+
uptime_seconds: float
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"hostname": self.hostname,
|
|
110
|
+
"platform": self.platform,
|
|
111
|
+
"pythonVersion": self.python_version,
|
|
112
|
+
"processId": self.process_id,
|
|
113
|
+
"memoryUsed": self.memory_rss,
|
|
114
|
+
"threadCount": self.thread_count,
|
|
115
|
+
"uptime": self.uptime_seconds,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class ErrorContext:
|
|
121
|
+
request: Optional[RequestContext] = None
|
|
122
|
+
user: Optional[UserContext] = None
|
|
123
|
+
server: Optional[ServerContext] = None
|
|
124
|
+
custom: dict[str, Any] = field(default_factory=dict)
|
|
125
|
+
breadcrumbs: list[Breadcrumb] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
def to_dict(self) -> dict[str, Any]:
|
|
128
|
+
return _omit_none(
|
|
129
|
+
{
|
|
130
|
+
"request": self.request.to_dict() if self.request else None,
|
|
131
|
+
"user": self.user.to_dict() if self.user else None,
|
|
132
|
+
"server": self.server.to_dict() if self.server else None,
|
|
133
|
+
"custom": self.custom or None,
|
|
134
|
+
"breadcrumbs": [b.to_dict() for b in self.breadcrumbs] or None,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class ErrorReport:
|
|
141
|
+
title: str
|
|
142
|
+
message: str
|
|
143
|
+
stack_trace: Optional[str] = None
|
|
144
|
+
environment: Optional[str] = None
|
|
145
|
+
severity: Optional[str] = None
|
|
146
|
+
tags: list[str] = field(default_factory=list)
|
|
147
|
+
fingerprint: Optional[str] = None
|
|
148
|
+
context: Optional[ErrorContext] = None
|
|
149
|
+
timestamp: Optional[int] = None # unix millis
|
|
150
|
+
|
|
151
|
+
def to_dict(self) -> dict[str, Any]:
|
|
152
|
+
return _omit_none(
|
|
153
|
+
{
|
|
154
|
+
"title": self.title,
|
|
155
|
+
"message": self.message,
|
|
156
|
+
"stack_trace": self.stack_trace,
|
|
157
|
+
"environment": self.environment,
|
|
158
|
+
"severity": self.severity,
|
|
159
|
+
"tags": self.tags or None,
|
|
160
|
+
"fingerprint": self.fingerprint,
|
|
161
|
+
"context": self.context.to_dict() if self.context else None,
|
|
162
|
+
"timestamp": self.timestamp,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
ShouldReportFunc = Callable[[BaseException], bool]
|
|
168
|
+
FingerprintFunc = Callable[[BaseException, Optional[ErrorContext]], str]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oluso
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AI-powered error monitoring for Python applications: automatic error reporting, breadcrumb tracking, and intelligent error grouping.
|
|
5
|
+
Project-URL: Homepage, https://github.com/olusodotdev/oluso-py
|
|
6
|
+
Project-URL: Repository, https://github.com/olusodotdev/oluso-py
|
|
7
|
+
Project-URL: Issues, https://github.com/olusodotdev/oluso-py/issues
|
|
8
|
+
Author: David Ezeh
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,crash-reporting,debugging,error,error-monitoring,error-tracking,logging,monitoring,reporting
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: flask>=2.3; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
29
|
+
Provides-Extra: flask
|
|
30
|
+
Requires-Dist: flask>=2.3; extra == 'flask'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# oluso-py
|
|
34
|
+
|
|
35
|
+
AI-powered error monitoring for Python applications: automatic error reporting, breadcrumb tracking, and intelligent error grouping.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install oluso
|
|
41
|
+
|
|
42
|
+
# With a framework integration
|
|
43
|
+
pip install "oluso[flask]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage with Flask
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from flask import Flask
|
|
50
|
+
from oluso import Oluso, Options
|
|
51
|
+
from oluso.integrations.flask import init_app
|
|
52
|
+
|
|
53
|
+
app = Flask(__name__)
|
|
54
|
+
|
|
55
|
+
client = Oluso(Options(api_key="your-api-key", environment="production"))
|
|
56
|
+
init_app(app, client)
|
|
57
|
+
|
|
58
|
+
@app.route("/")
|
|
59
|
+
def index():
|
|
60
|
+
raise RuntimeError("something went wrong") # captured and reported automatically
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`init_app` wraps `app.wsgi_app`, so it works with any WSGI application, not just Flask. It scopes breadcrumbs to each request, auto-reports unhandled exceptions and 5xx responses, then re-raises so Flask/Werkzeug's own error handling still runs — Oluso only observes and reports, it never changes how your app responds to errors.
|
|
64
|
+
|
|
65
|
+
## Breadcrumbs & User Context
|
|
66
|
+
|
|
67
|
+
Breadcrumbs and user context are scoped per request using `contextvars` — Python's idiomatic equivalent of the `AsyncLocalStorage`-based scoping the Node SDK uses (and Go's `context.Context`), correctly isolated per thread *and* per asyncio task:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from oluso import add_breadcrumb, set_user
|
|
71
|
+
|
|
72
|
+
@app.route("/checkout")
|
|
73
|
+
def checkout():
|
|
74
|
+
add_breadcrumb("user started checkout", category="action")
|
|
75
|
+
set_user(UserContext(id="user_456"))
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
do_checkout()
|
|
79
|
+
except Exception as err:
|
|
80
|
+
client.capture_exception(err, {"cartId": "cart_123"})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For non-request work (a background job, a CLI command) where you still want a scope, open one yourself:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from oluso import scope, add_breadcrumb
|
|
87
|
+
|
|
88
|
+
with scope():
|
|
89
|
+
add_breadcrumb("job started")
|
|
90
|
+
client.capture_exception(err)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Manual Reporting
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
client.capture_exception(err, {"customMeta": "extra-info"})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Advanced Configuration
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from oluso import Oluso, Options, Severity
|
|
103
|
+
|
|
104
|
+
client = Oluso(Options(
|
|
105
|
+
api_key="your-api-key",
|
|
106
|
+
endpoint="https://api.oluso.dev/api/v1/error/report", # override for self-hosting
|
|
107
|
+
environment="staging",
|
|
108
|
+
default_severity=Severity.MEDIUM,
|
|
109
|
+
max_breadcrumbs=50,
|
|
110
|
+
max_errors_per_minute=100,
|
|
111
|
+
sensitive_keys=["ssn", "internal_id"],
|
|
112
|
+
should_report=lambda err: "expected" not in str(err),
|
|
113
|
+
))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Call `client.flush(timeout=5)` before your process exits so a capture right before shutdown isn't lost.
|
|
117
|
+
|
|
118
|
+
## Error Report Structure
|
|
119
|
+
|
|
120
|
+
Reports sent to the API include:
|
|
121
|
+
|
|
122
|
+
- **Metadata**: Title, message, stack trace, severity, tags.
|
|
123
|
+
- **Context**: Request details (URL, method, headers, etc.), server details (hostname, Python version, memory, thread count).
|
|
124
|
+
- **History**: Breadcrumbs leading up to the error.
|
|
125
|
+
- **Identification**: Fingerprint for deduplication and user ID.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
oluso/__init__.py,sha256=0oPV61l3CYTNi7m6pxuMHnSb7L81v9dYn8TmvDdjWGw,661
|
|
2
|
+
oluso/client.py,sha256=jD92oyDeQSs8sfU941xLAEvi7t7fxngMdKSkPfY1Mc0,7937
|
|
3
|
+
oluso/context.py,sha256=XwZkAbNSmO0V7tiZPRT6m2hMr_pz--4b9pnFxzpBmcc,3644
|
|
4
|
+
oluso/fingerprint.py,sha256=hLVQH83T0_0MPzmzB2nZzG3ZOokxlWVFmFT9l0ZiwjM,1843
|
|
5
|
+
oluso/options.py,sha256=ZuvOzZ1DZ9yLf4zLI2JyhhXKMQ2h8sw2Ol28SyHmdaE,1207
|
|
6
|
+
oluso/queue.py,sha256=9GwyNcRuyART8vu2LzrWLCEX8qtgtLn29XKURTI8rug,3652
|
|
7
|
+
oluso/rate_limiter.py,sha256=LDYzDji3Veg7AJ0WXW_33uq_s7WDoMUlKsCNIkdSYbE,1134
|
|
8
|
+
oluso/sanitizer.py,sha256=kI1FHPCblvmA7tY0uY4xj6ia6Wys93xFHNCAA-wsfxI,2972
|
|
9
|
+
oluso/server_context.py,sha256=JYHLqlSSMBlLcC4kgZjl01BLYagruiCiN1tKT2F89O8,928
|
|
10
|
+
oluso/transport.py,sha256=RUcrCNTCUQre6GEB4bUwfGEbeCnQK-wG8wTe3fLdJNI,1522
|
|
11
|
+
oluso/types.py,sha256=9AszCW1ttgclKkLn6R4iBg7rqUWo_C0Uf5oVe7KZ_Ow,4843
|
|
12
|
+
oluso/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
oluso/integrations/flask.py,sha256=veV8VX9N7pSIv9mTeFSi6CTtFTPtJwG1wnANj9D5qqk,6006
|
|
14
|
+
oluso-1.0.0.dist-info/METADATA,sha256=RAc2aMhalql78siW8lqBL9grzqvRmFRnG3-OfEF1FEk,4267
|
|
15
|
+
oluso-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
16
|
+
oluso-1.0.0.dist-info/licenses/LICENSE,sha256=MdjdOgyMeHkqBppsOqxjWO-RTFx-uYrPvuRTSMJEFm4,1067
|
|
17
|
+
oluso-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Ezeh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|