uncaughtdev 0.1.0__tar.gz
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.
- uncaughtdev-0.1.0/.gitignore +14 -0
- uncaughtdev-0.1.0/PKG-INFO +117 -0
- uncaughtdev-0.1.0/README.md +75 -0
- uncaughtdev-0.1.0/pyproject.toml +45 -0
- uncaughtdev-0.1.0/src/uncaught/__init__.py +14 -0
- uncaughtdev-0.1.0/src/uncaught/breadcrumbs.py +43 -0
- uncaughtdev-0.1.0/src/uncaught/client.py +245 -0
- uncaughtdev-0.1.0/src/uncaught/env_detector.py +50 -0
- uncaughtdev-0.1.0/src/uncaught/fingerprint.py +225 -0
- uncaughtdev-0.1.0/src/uncaught/integrations/__init__.py +1 -0
- uncaughtdev-0.1.0/src/uncaught/integrations/django.py +65 -0
- uncaughtdev-0.1.0/src/uncaught/integrations/fastapi.py +55 -0
- uncaughtdev-0.1.0/src/uncaught/integrations/flask.py +53 -0
- uncaughtdev-0.1.0/src/uncaught/integrations/sqlalchemy.py +57 -0
- uncaughtdev-0.1.0/src/uncaught/prompt_builder.py +170 -0
- uncaughtdev-0.1.0/src/uncaught/rate_limiter.py +30 -0
- uncaughtdev-0.1.0/src/uncaught/sanitizer.py +66 -0
- uncaughtdev-0.1.0/src/uncaught/transport.py +300 -0
- uncaughtdev-0.1.0/src/uncaught/types.py +124 -0
- uncaughtdev-0.1.0/src/uncaught/utils.py +40 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uncaughtdev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first, AI-ready error monitoring for Python — catch bugs, get AI-powered fixes
|
|
5
|
+
Project-URL: Homepage, https://github.com/ajeeshworkspace/uncaught
|
|
6
|
+
Project-URL: Documentation, https://github.com/ajeeshworkspace/uncaught
|
|
7
|
+
Project-URL: Repository, https://github.com/ajeeshworkspace/uncaught
|
|
8
|
+
Author: Uncaught Dev
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai,debugging,error-monitoring,mcp,vibe-coding
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Framework :: Flask
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: django>=3.2; extra == 'all'
|
|
27
|
+
Requires-Dist: fastapi>=0.68.0; extra == 'all'
|
|
28
|
+
Requires-Dist: flask>=2.0.0; extra == 'all'
|
|
29
|
+
Requires-Dist: sqlalchemy>=1.4.0; extra == 'all'
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
33
|
+
Provides-Extra: django
|
|
34
|
+
Requires-Dist: django>=3.2; extra == 'django'
|
|
35
|
+
Provides-Extra: fastapi
|
|
36
|
+
Requires-Dist: fastapi>=0.68.0; extra == 'fastapi'
|
|
37
|
+
Provides-Extra: flask
|
|
38
|
+
Requires-Dist: flask>=2.0.0; extra == 'flask'
|
|
39
|
+
Provides-Extra: sqlalchemy
|
|
40
|
+
Requires-Dist: sqlalchemy>=1.4.0; extra == 'sqlalchemy'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# uncaught
|
|
44
|
+
|
|
45
|
+
Local-first, AI-ready error monitoring for Python. Zero-config error capture with automatic fix prompts.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install uncaught
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from uncaught import init_uncaught
|
|
57
|
+
|
|
58
|
+
client = init_uncaught({
|
|
59
|
+
"environment": "development",
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
# Errors are automatically captured via sys.excepthook
|
|
63
|
+
# Or capture manually:
|
|
64
|
+
try:
|
|
65
|
+
risky_operation()
|
|
66
|
+
except Exception as e:
|
|
67
|
+
client.capture_error(e)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Framework Integrations
|
|
71
|
+
|
|
72
|
+
### FastAPI
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from fastapi import FastAPI
|
|
76
|
+
from uncaught.integrations.fastapi import UncaughtMiddleware
|
|
77
|
+
|
|
78
|
+
app = FastAPI()
|
|
79
|
+
app.add_middleware(UncaughtMiddleware)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Flask
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from flask import Flask
|
|
86
|
+
from uncaught.integrations.flask import init_app
|
|
87
|
+
|
|
88
|
+
app = Flask(__name__)
|
|
89
|
+
init_app(app)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Django
|
|
93
|
+
|
|
94
|
+
Add to `MIDDLEWARE` in `settings.py`:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
MIDDLEWARE = [
|
|
98
|
+
"uncaught.integrations.django.UncaughtMiddleware",
|
|
99
|
+
# ...
|
|
100
|
+
]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### SQLAlchemy
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from uncaught.integrations.sqlalchemy import setup_sqlalchemy
|
|
107
|
+
|
|
108
|
+
setup_sqlalchemy(engine)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## How It Works
|
|
112
|
+
|
|
113
|
+
Errors are written to a local `.uncaught/` directory with AI-ready fix prompts. Use the MCP server to query errors from Claude, Cursor, or any MCP-compatible AI tool.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# uncaught
|
|
2
|
+
|
|
3
|
+
Local-first, AI-ready error monitoring for Python. Zero-config error capture with automatic fix prompts.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install uncaught
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from uncaught import init_uncaught
|
|
15
|
+
|
|
16
|
+
client = init_uncaught({
|
|
17
|
+
"environment": "development",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
# Errors are automatically captured via sys.excepthook
|
|
21
|
+
# Or capture manually:
|
|
22
|
+
try:
|
|
23
|
+
risky_operation()
|
|
24
|
+
except Exception as e:
|
|
25
|
+
client.capture_error(e)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Framework Integrations
|
|
29
|
+
|
|
30
|
+
### FastAPI
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from fastapi import FastAPI
|
|
34
|
+
from uncaught.integrations.fastapi import UncaughtMiddleware
|
|
35
|
+
|
|
36
|
+
app = FastAPI()
|
|
37
|
+
app.add_middleware(UncaughtMiddleware)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Flask
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from flask import Flask
|
|
44
|
+
from uncaught.integrations.flask import init_app
|
|
45
|
+
|
|
46
|
+
app = Flask(__name__)
|
|
47
|
+
init_app(app)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Django
|
|
51
|
+
|
|
52
|
+
Add to `MIDDLEWARE` in `settings.py`:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
MIDDLEWARE = [
|
|
56
|
+
"uncaught.integrations.django.UncaughtMiddleware",
|
|
57
|
+
# ...
|
|
58
|
+
]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### SQLAlchemy
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from uncaught.integrations.sqlalchemy import setup_sqlalchemy
|
|
65
|
+
|
|
66
|
+
setup_sqlalchemy(engine)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## How It Works
|
|
70
|
+
|
|
71
|
+
Errors are written to a local `.uncaught/` directory with AI-ready fix prompts. Use the MCP server to query errors from Claude, Cursor, or any MCP-compatible AI tool.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uncaughtdev"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local-first, AI-ready error monitoring for Python — catch bugs, get AI-powered fixes"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Uncaught Dev" }]
|
|
13
|
+
keywords = ["error-monitoring", "debugging", "ai", "mcp", "vibe-coding"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: FastAPI",
|
|
17
|
+
"Framework :: Flask",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Software Development :: Debuggers",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
fastapi = ["fastapi>=0.68.0"]
|
|
33
|
+
flask = ["flask>=2.0.0"]
|
|
34
|
+
django = ["django>=3.2"]
|
|
35
|
+
sqlalchemy = ["sqlalchemy>=1.4.0"]
|
|
36
|
+
all = ["fastapi>=0.68.0", "flask>=2.0.0", "django>=3.2", "sqlalchemy>=1.4.0"]
|
|
37
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21.0"]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/ajeeshworkspace/uncaught"
|
|
41
|
+
Documentation = "https://github.com/ajeeshworkspace/uncaught"
|
|
42
|
+
Repository = "https://github.com/ajeeshworkspace/uncaught"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/uncaught"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Uncaught — local-first, AI-ready error monitoring for Python."""
|
|
2
|
+
|
|
3
|
+
from uncaught.client import UncaughtClient, init_uncaught, get_client
|
|
4
|
+
from uncaught.types import UncaughtConfig, SeverityLevel
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"UncaughtClient",
|
|
9
|
+
"init_uncaught",
|
|
10
|
+
"get_client",
|
|
11
|
+
"UncaughtConfig",
|
|
12
|
+
"SeverityLevel",
|
|
13
|
+
"__version__",
|
|
14
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Ring-buffer breadcrumb store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections import deque
|
|
7
|
+
|
|
8
|
+
from uncaught.types import Breadcrumb
|
|
9
|
+
from uncaught.utils import iso_timestamp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BreadcrumbStore:
|
|
13
|
+
"""Thread-safe ring buffer for breadcrumbs."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, max_breadcrumbs: int = 20) -> None:
|
|
16
|
+
self._buffer: deque[Breadcrumb] = deque(maxlen=max_breadcrumbs)
|
|
17
|
+
|
|
18
|
+
def add(self, crumb: dict) -> None:
|
|
19
|
+
"""Append a breadcrumb, auto-adding a timestamp if missing."""
|
|
20
|
+
entry: Breadcrumb = {
|
|
21
|
+
"type": crumb.get("type", "custom"),
|
|
22
|
+
"category": crumb.get("category", ""),
|
|
23
|
+
"message": crumb.get("message", ""),
|
|
24
|
+
"timestamp": crumb.get("timestamp", iso_timestamp()),
|
|
25
|
+
}
|
|
26
|
+
if crumb.get("data"):
|
|
27
|
+
entry["data"] = crumb["data"]
|
|
28
|
+
if crumb.get("level"):
|
|
29
|
+
entry["level"] = crumb["level"]
|
|
30
|
+
self._buffer.append(entry)
|
|
31
|
+
|
|
32
|
+
def get_all(self) -> list[Breadcrumb]:
|
|
33
|
+
"""Return all stored breadcrumbs in chronological order (copies)."""
|
|
34
|
+
return [copy.deepcopy(b) for b in self._buffer]
|
|
35
|
+
|
|
36
|
+
def get_last(self, n: int) -> list[Breadcrumb]:
|
|
37
|
+
"""Return the most recent n breadcrumbs (copies)."""
|
|
38
|
+
items = list(self._buffer)[-n:]
|
|
39
|
+
return [copy.deepcopy(b) for b in items]
|
|
40
|
+
|
|
41
|
+
def clear(self) -> None:
|
|
42
|
+
"""Empty the buffer."""
|
|
43
|
+
self._buffer.clear()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Core Uncaught client — the main error capture pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from uncaught.breadcrumbs import BreadcrumbStore
|
|
11
|
+
from uncaught.env_detector import detect_environment
|
|
12
|
+
from uncaught.fingerprint import generate_fingerprint
|
|
13
|
+
from uncaught.prompt_builder import build_fix_prompt
|
|
14
|
+
from uncaught.rate_limiter import RateLimiter
|
|
15
|
+
from uncaught.sanitizer import sanitize
|
|
16
|
+
from uncaught.transport import ConsoleTransport, LocalFileTransport
|
|
17
|
+
from uncaught.types import Breadcrumb, UncaughtConfig, UncaughtEvent
|
|
18
|
+
from uncaught.utils import generate_uuid, iso_timestamp
|
|
19
|
+
|
|
20
|
+
# Global singleton
|
|
21
|
+
_client: UncaughtClient | None = None
|
|
22
|
+
|
|
23
|
+
SDK_NAME = "uncaught-python"
|
|
24
|
+
SDK_VERSION = "0.1.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UncaughtClient:
|
|
28
|
+
"""The main Uncaught client that captures, processes, and stores errors."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: UncaughtConfig | None = None) -> None:
|
|
31
|
+
config = config or {}
|
|
32
|
+
self._config = config
|
|
33
|
+
self._enabled = config.get("enabled", True)
|
|
34
|
+
self._debug = config.get("debug", False)
|
|
35
|
+
self._environment = config.get("environment")
|
|
36
|
+
self._release = config.get("release")
|
|
37
|
+
self._before_send = config.get("before_send")
|
|
38
|
+
self._sanitize_keys = config.get("sanitize_keys", [])
|
|
39
|
+
self._ignore_errors = config.get("ignore_errors", [])
|
|
40
|
+
self._webhook_url = config.get("webhook_url")
|
|
41
|
+
|
|
42
|
+
self._breadcrumbs = BreadcrumbStore(config.get("max_breadcrumbs", 20))
|
|
43
|
+
self._rate_limiter = RateLimiter(config.get("max_events_per_minute", 30))
|
|
44
|
+
self._seen_fingerprints: set[str] = set()
|
|
45
|
+
|
|
46
|
+
# Setup transport
|
|
47
|
+
transport_mode = config.get("transport", "local")
|
|
48
|
+
if transport_mode == "console":
|
|
49
|
+
self._transport = ConsoleTransport()
|
|
50
|
+
else:
|
|
51
|
+
self._transport = LocalFileTransport(config.get("local_output_dir"))
|
|
52
|
+
|
|
53
|
+
# Set user context
|
|
54
|
+
self._user: dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
def capture_error(
|
|
57
|
+
self,
|
|
58
|
+
error: BaseException | str | dict | Any,
|
|
59
|
+
*,
|
|
60
|
+
level: str = "error",
|
|
61
|
+
request: dict | None = None,
|
|
62
|
+
operation: dict | None = None,
|
|
63
|
+
user: dict | None = None,
|
|
64
|
+
) -> str | None:
|
|
65
|
+
"""Capture an error through the full processing pipeline.
|
|
66
|
+
|
|
67
|
+
Returns the event ID if sent, None if dropped.
|
|
68
|
+
"""
|
|
69
|
+
if not self._enabled:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
# Normalise the error
|
|
74
|
+
error_info = self._normalise_error(error)
|
|
75
|
+
|
|
76
|
+
# Check ignore list
|
|
77
|
+
if self._should_ignore(error_info.get("message", "")):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
# Generate fingerprint
|
|
81
|
+
fingerprint = generate_fingerprint(error_info)
|
|
82
|
+
|
|
83
|
+
# Rate limit
|
|
84
|
+
if not self._rate_limiter.should_allow(fingerprint):
|
|
85
|
+
if self._debug:
|
|
86
|
+
print(f"[uncaught] Rate limited: {fingerprint}")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Build event
|
|
90
|
+
event: UncaughtEvent = {
|
|
91
|
+
"eventId": generate_uuid(),
|
|
92
|
+
"timestamp": iso_timestamp(),
|
|
93
|
+
"level": level,
|
|
94
|
+
"fingerprint": fingerprint,
|
|
95
|
+
"error": error_info,
|
|
96
|
+
"breadcrumbs": self._breadcrumbs.get_all(),
|
|
97
|
+
"environment": detect_environment(),
|
|
98
|
+
"sdk": {"name": SDK_NAME, "version": SDK_VERSION},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if self._release:
|
|
102
|
+
event["release"] = self._release
|
|
103
|
+
if self._environment:
|
|
104
|
+
event["environment"]["deploy"] = self._environment
|
|
105
|
+
if request:
|
|
106
|
+
event["request"] = request
|
|
107
|
+
if operation:
|
|
108
|
+
event["operation"] = operation
|
|
109
|
+
if user or self._user:
|
|
110
|
+
event["user"] = {**self._user, **(user or {})}
|
|
111
|
+
if self._config.get("project_key"):
|
|
112
|
+
event["projectKey"] = self._config["project_key"]
|
|
113
|
+
|
|
114
|
+
# Build fix prompt
|
|
115
|
+
event["fixPrompt"] = build_fix_prompt(event)
|
|
116
|
+
|
|
117
|
+
# Sanitize
|
|
118
|
+
event = sanitize(event, self._sanitize_keys)
|
|
119
|
+
|
|
120
|
+
# Before send hook
|
|
121
|
+
if self._before_send:
|
|
122
|
+
result = self._before_send(event)
|
|
123
|
+
if result is None:
|
|
124
|
+
return None
|
|
125
|
+
event = result
|
|
126
|
+
|
|
127
|
+
# Send
|
|
128
|
+
self._transport.send(event)
|
|
129
|
+
|
|
130
|
+
# Webhook on first occurrence
|
|
131
|
+
if self._webhook_url and fingerprint not in self._seen_fingerprints:
|
|
132
|
+
self._seen_fingerprints.add(fingerprint)
|
|
133
|
+
self._send_webhook(event)
|
|
134
|
+
|
|
135
|
+
if self._debug:
|
|
136
|
+
print(f"[uncaught] Captured: {error_info.get('type')}: {error_info.get('message')} ({fingerprint})")
|
|
137
|
+
|
|
138
|
+
return event["eventId"]
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
if self._debug:
|
|
142
|
+
print(f"[uncaught] Internal error: {e}")
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def capture_exception(self, **kwargs: Any) -> str | None:
|
|
146
|
+
"""Capture the current exception from sys.exc_info()."""
|
|
147
|
+
exc_info = sys.exc_info()
|
|
148
|
+
if exc_info[1] is not None:
|
|
149
|
+
return self.capture_error(exc_info[1], **kwargs)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def add_breadcrumb(self, crumb: dict) -> None:
|
|
153
|
+
"""Add a breadcrumb to the ring buffer."""
|
|
154
|
+
self._breadcrumbs.add(crumb)
|
|
155
|
+
|
|
156
|
+
def set_user(self, user: dict[str, Any]) -> None:
|
|
157
|
+
"""Set user context for all subsequent events."""
|
|
158
|
+
self._user = user
|
|
159
|
+
|
|
160
|
+
def flush(self) -> None:
|
|
161
|
+
"""Flush pending events."""
|
|
162
|
+
self._transport.flush()
|
|
163
|
+
|
|
164
|
+
def _normalise_error(self, error: BaseException | str | dict | Any) -> dict:
|
|
165
|
+
"""Normalise any error type into a structured ErrorInfo dict."""
|
|
166
|
+
if isinstance(error, BaseException):
|
|
167
|
+
tb = "".join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
168
|
+
return {
|
|
169
|
+
"message": str(error),
|
|
170
|
+
"type": type(error).__name__,
|
|
171
|
+
"stack": tb,
|
|
172
|
+
}
|
|
173
|
+
if isinstance(error, str):
|
|
174
|
+
return {"message": error, "type": "Error"}
|
|
175
|
+
if isinstance(error, dict):
|
|
176
|
+
return {
|
|
177
|
+
"message": error.get("message", str(error)),
|
|
178
|
+
"type": error.get("type", "Error"),
|
|
179
|
+
"stack": error.get("stack", ""),
|
|
180
|
+
}
|
|
181
|
+
return {"message": str(error), "type": type(error).__name__}
|
|
182
|
+
|
|
183
|
+
def _should_ignore(self, message: str) -> bool:
|
|
184
|
+
"""Check if the error message matches any ignore pattern."""
|
|
185
|
+
for pattern in self._ignore_errors:
|
|
186
|
+
if isinstance(pattern, str):
|
|
187
|
+
if pattern in message:
|
|
188
|
+
return True
|
|
189
|
+
else:
|
|
190
|
+
try:
|
|
191
|
+
if re.search(pattern, message):
|
|
192
|
+
return True
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
def _send_webhook(self, event: UncaughtEvent) -> None:
|
|
198
|
+
"""Fire-and-forget webhook notification."""
|
|
199
|
+
try:
|
|
200
|
+
import urllib.request
|
|
201
|
+
import json
|
|
202
|
+
error = event.get("error", {})
|
|
203
|
+
payload = json.dumps({
|
|
204
|
+
"title": error.get("message", ""),
|
|
205
|
+
"errorType": error.get("type", ""),
|
|
206
|
+
"fingerprint": event.get("fingerprint", ""),
|
|
207
|
+
"level": event.get("level", ""),
|
|
208
|
+
"timestamp": event.get("timestamp", ""),
|
|
209
|
+
"release": event.get("release"),
|
|
210
|
+
"environment": event.get("environment", {}).get("deploy"),
|
|
211
|
+
"fixPrompt": event.get("fixPrompt", ""),
|
|
212
|
+
}).encode("utf-8")
|
|
213
|
+
req = urllib.request.Request(
|
|
214
|
+
self._webhook_url,
|
|
215
|
+
data=payload,
|
|
216
|
+
headers={"Content-Type": "application/json"},
|
|
217
|
+
)
|
|
218
|
+
urllib.request.urlopen(req, timeout=5)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass # Fire and forget
|
|
221
|
+
|
|
222
|
+
def _setup_global_handlers(self) -> None:
|
|
223
|
+
"""Install global exception hooks."""
|
|
224
|
+
original_excepthook = sys.excepthook
|
|
225
|
+
|
|
226
|
+
def uncaught_excepthook(exc_type: type, exc_value: BaseException, exc_tb: Any) -> None:
|
|
227
|
+
self.capture_error(exc_value, level="fatal")
|
|
228
|
+
original_excepthook(exc_type, exc_value, exc_tb)
|
|
229
|
+
|
|
230
|
+
sys.excepthook = uncaught_excepthook
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def init_uncaught(config: UncaughtConfig | None = None) -> UncaughtClient:
|
|
234
|
+
"""Initialize the global Uncaught client singleton."""
|
|
235
|
+
global _client
|
|
236
|
+
_client = UncaughtClient(config)
|
|
237
|
+
_client._setup_global_handlers()
|
|
238
|
+
return _client
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_client() -> UncaughtClient:
|
|
242
|
+
"""Get the global Uncaught client. Raises if not initialized."""
|
|
243
|
+
if _client is None:
|
|
244
|
+
raise RuntimeError("Uncaught not initialized. Call init_uncaught() first.")
|
|
245
|
+
return _client
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Environment detection for Python runtimes and frameworks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from uncaught.types import EnvironmentInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect_environment() -> EnvironmentInfo:
|
|
12
|
+
"""Detect the current Python runtime, framework, and platform."""
|
|
13
|
+
info: EnvironmentInfo = {
|
|
14
|
+
"runtime": "Python",
|
|
15
|
+
"runtimeVersion": platform.python_version(),
|
|
16
|
+
"platform": sys.platform,
|
|
17
|
+
"os": platform.system(),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Detect framework
|
|
21
|
+
if "fastapi" in sys.modules:
|
|
22
|
+
try:
|
|
23
|
+
import fastapi
|
|
24
|
+
info["framework"] = "FastAPI"
|
|
25
|
+
info["frameworkVersion"] = fastapi.__version__
|
|
26
|
+
except (ImportError, AttributeError):
|
|
27
|
+
pass
|
|
28
|
+
elif "flask" in sys.modules:
|
|
29
|
+
try:
|
|
30
|
+
import flask
|
|
31
|
+
info["framework"] = "Flask"
|
|
32
|
+
info["frameworkVersion"] = flask.__version__
|
|
33
|
+
except (ImportError, AttributeError):
|
|
34
|
+
pass
|
|
35
|
+
elif "django" in sys.modules:
|
|
36
|
+
try:
|
|
37
|
+
import django
|
|
38
|
+
info["framework"] = "Django"
|
|
39
|
+
info["frameworkVersion"] = django.get_version()
|
|
40
|
+
except (ImportError, AttributeError):
|
|
41
|
+
pass
|
|
42
|
+
elif "starlette" in sys.modules:
|
|
43
|
+
try:
|
|
44
|
+
import starlette
|
|
45
|
+
info["framework"] = "Starlette"
|
|
46
|
+
info["frameworkVersion"] = starlette.__version__
|
|
47
|
+
except (ImportError, AttributeError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
return info
|