oluso 1.0.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.
- oluso-1.0.0/.github/workflows/ci.yml +23 -0
- oluso-1.0.0/.gitignore +13 -0
- oluso-1.0.0/LICENSE +21 -0
- oluso-1.0.0/PKG-INFO +129 -0
- oluso-1.0.0/README.md +97 -0
- oluso-1.0.0/examples/flask_app.py +50 -0
- oluso-1.0.0/pyproject.toml +72 -0
- oluso-1.0.0/src/oluso/__init__.py +34 -0
- oluso-1.0.0/src/oluso/client.py +216 -0
- oluso-1.0.0/src/oluso/context.py +111 -0
- oluso-1.0.0/src/oluso/fingerprint.py +53 -0
- oluso-1.0.0/src/oluso/integrations/__init__.py +0 -0
- oluso-1.0.0/src/oluso/integrations/flask.py +151 -0
- oluso-1.0.0/src/oluso/options.py +42 -0
- oluso-1.0.0/src/oluso/queue.py +111 -0
- oluso-1.0.0/src/oluso/rate_limiter.py +36 -0
- oluso-1.0.0/src/oluso/sanitizer.py +100 -0
- oluso-1.0.0/src/oluso/server_context.py +34 -0
- oluso-1.0.0/src/oluso/transport.py +44 -0
- oluso-1.0.0/src/oluso/types.py +168 -0
- oluso-1.0.0/tests/__init__.py +0 -0
- oluso-1.0.0/tests/conftest.py +76 -0
- oluso-1.0.0/tests/test_client.py +89 -0
- oluso-1.0.0/tests/test_context.py +68 -0
- oluso-1.0.0/tests/test_fingerprint.py +26 -0
- oluso-1.0.0/tests/test_flask.py +85 -0
- oluso-1.0.0/tests/test_queue.py +69 -0
- oluso-1.0.0/tests/test_rate_limiter.py +36 -0
- oluso-1.0.0/tests/test_sanitizer.py +57 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build-and-test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: ruff check src tests
|
|
22
|
+
- run: mypy src
|
|
23
|
+
- run: pytest -v --cov=oluso --cov-report=term-missing
|
oluso-1.0.0/.gitignore
ADDED
oluso-1.0.0/LICENSE
ADDED
|
@@ -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.
|
oluso-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
oluso-1.0.0/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# oluso-py
|
|
2
|
+
|
|
3
|
+
AI-powered error monitoring for Python applications: automatic error reporting, breadcrumb tracking, and intelligent error grouping.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install oluso
|
|
9
|
+
|
|
10
|
+
# With a framework integration
|
|
11
|
+
pip install "oluso[flask]"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage with Flask
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from flask import Flask
|
|
18
|
+
from oluso import Oluso, Options
|
|
19
|
+
from oluso.integrations.flask import init_app
|
|
20
|
+
|
|
21
|
+
app = Flask(__name__)
|
|
22
|
+
|
|
23
|
+
client = Oluso(Options(api_key="your-api-key", environment="production"))
|
|
24
|
+
init_app(app, client)
|
|
25
|
+
|
|
26
|
+
@app.route("/")
|
|
27
|
+
def index():
|
|
28
|
+
raise RuntimeError("something went wrong") # captured and reported automatically
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`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.
|
|
32
|
+
|
|
33
|
+
## Breadcrumbs & User Context
|
|
34
|
+
|
|
35
|
+
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:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from oluso import add_breadcrumb, set_user
|
|
39
|
+
|
|
40
|
+
@app.route("/checkout")
|
|
41
|
+
def checkout():
|
|
42
|
+
add_breadcrumb("user started checkout", category="action")
|
|
43
|
+
set_user(UserContext(id="user_456"))
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
do_checkout()
|
|
47
|
+
except Exception as err:
|
|
48
|
+
client.capture_exception(err, {"cartId": "cart_123"})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For non-request work (a background job, a CLI command) where you still want a scope, open one yourself:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from oluso import scope, add_breadcrumb
|
|
55
|
+
|
|
56
|
+
with scope():
|
|
57
|
+
add_breadcrumb("job started")
|
|
58
|
+
client.capture_exception(err)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Manual Reporting
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client.capture_exception(err, {"customMeta": "extra-info"})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Advanced Configuration
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from oluso import Oluso, Options, Severity
|
|
71
|
+
|
|
72
|
+
client = Oluso(Options(
|
|
73
|
+
api_key="your-api-key",
|
|
74
|
+
endpoint="https://api.oluso.dev/api/v1/error/report", # override for self-hosting
|
|
75
|
+
environment="staging",
|
|
76
|
+
default_severity=Severity.MEDIUM,
|
|
77
|
+
max_breadcrumbs=50,
|
|
78
|
+
max_errors_per_minute=100,
|
|
79
|
+
sensitive_keys=["ssn", "internal_id"],
|
|
80
|
+
should_report=lambda err: "expected" not in str(err),
|
|
81
|
+
))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Call `client.flush(timeout=5)` before your process exits so a capture right before shutdown isn't lost.
|
|
85
|
+
|
|
86
|
+
## Error Report Structure
|
|
87
|
+
|
|
88
|
+
Reports sent to the API include:
|
|
89
|
+
|
|
90
|
+
- **Metadata**: Title, message, stack trace, severity, tags.
|
|
91
|
+
- **Context**: Request details (URL, method, headers, etc.), server details (hostname, Python version, memory, thread count).
|
|
92
|
+
- **History**: Breadcrumbs leading up to the error.
|
|
93
|
+
- **Identification**: Fingerprint for deduplication and user ID.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Minimal example of wiring Oluso into a Flask app.
|
|
2
|
+
|
|
3
|
+
pip install "oluso[flask]"
|
|
4
|
+
OLUSO_API_KEY=your-api-key python examples/flask_app.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import atexit
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from flask import Flask
|
|
11
|
+
|
|
12
|
+
from oluso import Oluso, Options, add_breadcrumb
|
|
13
|
+
from oluso.integrations.flask import init_app
|
|
14
|
+
|
|
15
|
+
client = Oluso(Options(api_key=os.environ.get("OLUSO_API_KEY", ""), environment="development"))
|
|
16
|
+
atexit.register(lambda: client.flush(timeout=5))
|
|
17
|
+
|
|
18
|
+
app = Flask(__name__)
|
|
19
|
+
init_app(app, client)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.route("/")
|
|
23
|
+
def index():
|
|
24
|
+
add_breadcrumb("handling root request")
|
|
25
|
+
return "ok"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.route("/boom")
|
|
29
|
+
def boom():
|
|
30
|
+
# Middleware auto-reports both raised exceptions and 5xx responses, so
|
|
31
|
+
# a deliberately failing view needs no manual capture at all.
|
|
32
|
+
raise RuntimeError("something went wrong")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.route("/manual")
|
|
36
|
+
def manual():
|
|
37
|
+
try:
|
|
38
|
+
do_work()
|
|
39
|
+
except Exception as err:
|
|
40
|
+
client.capture_exception(err, {"step": "manual example"})
|
|
41
|
+
return "internal error", 500
|
|
42
|
+
return "ok"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def do_work():
|
|
46
|
+
raise ValueError("work failed")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
app.run(port=8080)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oluso"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "AI-powered error monitoring for Python applications: automatic error reporting, breadcrumb tracking, and intelligent error grouping."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "David Ezeh" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = [
|
|
14
|
+
"error",
|
|
15
|
+
"monitoring",
|
|
16
|
+
"reporting",
|
|
17
|
+
"error-tracking",
|
|
18
|
+
"error-monitoring",
|
|
19
|
+
"ai",
|
|
20
|
+
"crash-reporting",
|
|
21
|
+
"logging",
|
|
22
|
+
"debugging",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"License :: OSI Approved :: MIT License",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Intended Audience :: Developers",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
]
|
|
36
|
+
# Core has zero required third-party dependencies -- it uses only the
|
|
37
|
+
# standard library (urllib for transport, contextvars for scoping), the same
|
|
38
|
+
# "framework-agnostic core, opt-in everything else" design as the Node/Go
|
|
39
|
+
# SDKs. Framework integrations declare their own deps as extras below.
|
|
40
|
+
dependencies = []
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
flask = ["flask>=2.3"]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=7.0",
|
|
46
|
+
"pytest-cov",
|
|
47
|
+
"flask>=2.3",
|
|
48
|
+
"mypy",
|
|
49
|
+
"ruff",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
Homepage = "https://github.com/olusodotdev/oluso-py"
|
|
54
|
+
Repository = "https://github.com/olusodotdev/oluso-py"
|
|
55
|
+
Issues = "https://github.com/olusodotdev/oluso-py/issues"
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/oluso"]
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 100
|
|
65
|
+
target-version = "py39"
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
# The installed mypy version's oldest supported check target; the package's
|
|
69
|
+
# actual floor is still requires-python = ">=3.9" above -- Python 3.9
|
|
70
|
+
# natively supports the PEP 585 generics (dict[str, Any], etc.) used here.
|
|
71
|
+
python_version = "3.10"
|
|
72
|
+
ignore_missing_imports = true
|
|
@@ -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
|
+
]
|
|
@@ -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"
|