watchdock-errors 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.
- watchdock_errors-0.1.0/PKG-INFO +104 -0
- watchdock_errors-0.1.0/README.md +85 -0
- watchdock_errors-0.1.0/pyproject.toml +40 -0
- watchdock_errors-0.1.0/setup.cfg +4 -0
- watchdock_errors-0.1.0/src/watchdock_errors/__init__.py +117 -0
- watchdock_errors-0.1.0/src/watchdock_errors/client.py +75 -0
- watchdock_errors-0.1.0/src/watchdock_errors/config.py +25 -0
- watchdock_errors-0.1.0/src/watchdock_errors/event.py +81 -0
- watchdock_errors-0.1.0/src/watchdock_errors/integrations/__init__.py +0 -0
- watchdock_errors-0.1.0/src/watchdock_errors/integrations/django.py +86 -0
- watchdock_errors-0.1.0/src/watchdock_errors/integrations/fastapi.py +68 -0
- watchdock_errors-0.1.0/src/watchdock_errors/utils.py +33 -0
- watchdock_errors-0.1.0/src/watchdock_errors.egg-info/PKG-INFO +104 -0
- watchdock_errors-0.1.0/src/watchdock_errors.egg-info/SOURCES.txt +17 -0
- watchdock_errors-0.1.0/src/watchdock_errors.egg-info/dependency_links.txt +1 -0
- watchdock_errors-0.1.0/src/watchdock_errors.egg-info/requires.txt +13 -0
- watchdock_errors-0.1.0/src/watchdock_errors.egg-info/top_level.txt +1 -0
- watchdock_errors-0.1.0/tests/test_client.py +45 -0
- watchdock_errors-0.1.0/tests/test_event.py +78 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchdock-errors
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Application error tracking SDK for the Watchdock platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: error-tracking,observability,watchdock
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Provides-Extra: django
|
|
11
|
+
Requires-Dist: django>=3.2; extra == "django"
|
|
12
|
+
Provides-Extra: fastapi
|
|
13
|
+
Requires-Dist: fastapi>=0.95; extra == "fastapi"
|
|
14
|
+
Requires-Dist: starlette>=0.27; extra == "fastapi"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
18
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# watchdock-errors
|
|
21
|
+
|
|
22
|
+
Python SDK for application-level error tracking on the [Watchdock](https://watchdock.cc) platform.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install watchdock-errors
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With framework extras:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install "watchdock-errors[django]"
|
|
34
|
+
pip install "watchdock-errors[fastapi]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import watchdock_errors
|
|
41
|
+
|
|
42
|
+
watchdock_errors.init(
|
|
43
|
+
api_key="wdk_xxx",
|
|
44
|
+
environment="production",
|
|
45
|
+
release="1.0.0",
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Django
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# settings.py
|
|
53
|
+
MIDDLEWARE = [
|
|
54
|
+
...
|
|
55
|
+
"watchdock_errors.integrations.django.DjangoErrorMiddleware",
|
|
56
|
+
]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or via `INSTALLED_APPS` for automatic registration:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
INSTALLED_APPS = [
|
|
63
|
+
...
|
|
64
|
+
"watchdock_errors.integrations.django",
|
|
65
|
+
]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### FastAPI
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from watchdock_errors.integrations.fastapi import setup_watchdock
|
|
72
|
+
|
|
73
|
+
setup_watchdock(app)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Manual capture
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Capture the current exception
|
|
80
|
+
try:
|
|
81
|
+
process_payment()
|
|
82
|
+
except Exception:
|
|
83
|
+
watchdock_errors.capture_exception()
|
|
84
|
+
|
|
85
|
+
# Capture a specific exception
|
|
86
|
+
watchdock_errors.capture_exception(exc)
|
|
87
|
+
|
|
88
|
+
# Capture a message
|
|
89
|
+
watchdock_errors.capture_message("Stripe webhook signature invalid")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## PII scrubbing
|
|
93
|
+
|
|
94
|
+
By default, `Authorization`, `Cookie`, and `X-Api-Key` headers are stripped and the request body is not sent. Set `send_pii=True` to disable scrubbing.
|
|
95
|
+
|
|
96
|
+
Use the `before_send` hook for custom scrubbing:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
def scrub(event):
|
|
100
|
+
event["request"]["headers"].pop("X-Internal-Token", None)
|
|
101
|
+
return event # return None to drop the event entirely
|
|
102
|
+
|
|
103
|
+
watchdock_errors.init(api_key="wdk_xxx", before_send=scrub)
|
|
104
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# watchdock-errors
|
|
2
|
+
|
|
3
|
+
Python SDK for application-level error tracking on the [Watchdock](https://watchdock.cc) platform.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install watchdock-errors
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
With framework extras:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install "watchdock-errors[django]"
|
|
15
|
+
pip install "watchdock-errors[fastapi]"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import watchdock_errors
|
|
22
|
+
|
|
23
|
+
watchdock_errors.init(
|
|
24
|
+
api_key="wdk_xxx",
|
|
25
|
+
environment="production",
|
|
26
|
+
release="1.0.0",
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Django
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# settings.py
|
|
34
|
+
MIDDLEWARE = [
|
|
35
|
+
...
|
|
36
|
+
"watchdock_errors.integrations.django.DjangoErrorMiddleware",
|
|
37
|
+
]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or via `INSTALLED_APPS` for automatic registration:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
INSTALLED_APPS = [
|
|
44
|
+
...
|
|
45
|
+
"watchdock_errors.integrations.django",
|
|
46
|
+
]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### FastAPI
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from watchdock_errors.integrations.fastapi import setup_watchdock
|
|
53
|
+
|
|
54
|
+
setup_watchdock(app)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Manual capture
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
# Capture the current exception
|
|
61
|
+
try:
|
|
62
|
+
process_payment()
|
|
63
|
+
except Exception:
|
|
64
|
+
watchdock_errors.capture_exception()
|
|
65
|
+
|
|
66
|
+
# Capture a specific exception
|
|
67
|
+
watchdock_errors.capture_exception(exc)
|
|
68
|
+
|
|
69
|
+
# Capture a message
|
|
70
|
+
watchdock_errors.capture_message("Stripe webhook signature invalid")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## PII scrubbing
|
|
74
|
+
|
|
75
|
+
By default, `Authorization`, `Cookie`, and `X-Api-Key` headers are stripped and the request body is not sent. Set `send_pii=True` to disable scrubbing.
|
|
76
|
+
|
|
77
|
+
Use the `before_send` hook for custom scrubbing:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
def scrub(event):
|
|
81
|
+
event["request"]["headers"].pop("X-Internal-Token", None)
|
|
82
|
+
return event # return None to drop the event entirely
|
|
83
|
+
|
|
84
|
+
watchdock_errors.init(api_key="wdk_xxx", before_send=scrub)
|
|
85
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "watchdock-errors"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Application error tracking SDK for the Watchdock platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["error-tracking", "observability", "watchdock"]
|
|
13
|
+
dependencies = ["requests>=2.28"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
django = ["django>=3.2"]
|
|
17
|
+
fastapi = ["fastapi>=0.95", "starlette>=0.27"]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=7.0",
|
|
20
|
+
"pytest-mock>=3.0",
|
|
21
|
+
"responses>=0.23",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 120
|
|
29
|
+
target-version = "py310"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "W", "F", "I", "B", "UP"]
|
|
33
|
+
ignore = ["E501", "B008"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.format]
|
|
36
|
+
quote-style = "double"
|
|
37
|
+
indent-style = "space"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
watchdock-errors — Application error tracking SDK for the Watchdock platform.
|
|
3
|
+
|
|
4
|
+
Quickstart:
|
|
5
|
+
import watchdock_errors
|
|
6
|
+
|
|
7
|
+
watchdock_errors.init(api_key="wdk_xxx", environment="production")
|
|
8
|
+
|
|
9
|
+
# Automatic capture via middleware (Django / FastAPI integrations).
|
|
10
|
+
# Manual capture:
|
|
11
|
+
try:
|
|
12
|
+
risky_operation()
|
|
13
|
+
except Exception as exc:
|
|
14
|
+
watchdock_errors.capture_exception(exc)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
import logging
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .config import SDKConfig
|
|
25
|
+
from .client import WatchdockClient
|
|
26
|
+
|
|
27
|
+
__all__ = ["init", "capture_exception", "capture_message", "flush", "close"]
|
|
28
|
+
|
|
29
|
+
_client: "WatchdockClient | None" = None
|
|
30
|
+
_config: "SDKConfig | None" = None
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("watchdock_errors")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def init(
|
|
36
|
+
api_key: str,
|
|
37
|
+
endpoint: str = "https://api.watchdock.cc",
|
|
38
|
+
environment: str = "production",
|
|
39
|
+
release: str | None = None,
|
|
40
|
+
server_name: str | None = None,
|
|
41
|
+
send_pii: bool = False,
|
|
42
|
+
before_send=None,
|
|
43
|
+
timeout: float = 1.0,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Initialise the Watchdock Errors SDK.
|
|
47
|
+
|
|
48
|
+
Must be called once at application startup before any errors are captured.
|
|
49
|
+
"""
|
|
50
|
+
global _client, _config
|
|
51
|
+
|
|
52
|
+
from .config import SDKConfig
|
|
53
|
+
from .client import WatchdockClient
|
|
54
|
+
|
|
55
|
+
_config = SDKConfig(
|
|
56
|
+
api_key=api_key,
|
|
57
|
+
endpoint=endpoint,
|
|
58
|
+
environment=environment,
|
|
59
|
+
release=release,
|
|
60
|
+
server_name=server_name,
|
|
61
|
+
send_pii=send_pii,
|
|
62
|
+
before_send=before_send,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
)
|
|
65
|
+
_client = WatchdockClient(_config)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def capture_exception(exc: BaseException | None = None, request_context: dict | None = None) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Capture an exception and send it to Watchdock.
|
|
71
|
+
|
|
72
|
+
If ``exc`` is None the current exception from ``sys.exc_info()`` is used.
|
|
73
|
+
Safe to call outside of an except block — does nothing if there is no
|
|
74
|
+
active exception and ``exc`` is not provided.
|
|
75
|
+
"""
|
|
76
|
+
if _client is None or _config is None:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if exc is None:
|
|
80
|
+
exc_info = sys.exc_info()
|
|
81
|
+
exc = exc_info[1]
|
|
82
|
+
|
|
83
|
+
if exc is None:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
from .event import build_event
|
|
87
|
+
|
|
88
|
+
event = build_event(exc, _config, request_context=request_context)
|
|
89
|
+
if event is not None:
|
|
90
|
+
_client.capture(event)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def capture_message(message: str, request_context: dict | None = None) -> None:
|
|
94
|
+
"""Capture an arbitrary message string and send it to Watchdock."""
|
|
95
|
+
if _client is None or _config is None:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
from .event import build_event
|
|
99
|
+
|
|
100
|
+
event = build_event(None, _config, message=message, request_context=request_context)
|
|
101
|
+
if event is not None:
|
|
102
|
+
_client.capture(event)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def flush(timeout: float = 2.0) -> None:
|
|
106
|
+
"""Block until all queued events have been sent or timeout expires."""
|
|
107
|
+
if _client is not None:
|
|
108
|
+
_client.flush(timeout=timeout)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def close() -> None:
|
|
112
|
+
"""Gracefully shut down the SDK and drain the event queue."""
|
|
113
|
+
global _client, _config
|
|
114
|
+
if _client is not None:
|
|
115
|
+
_client.close()
|
|
116
|
+
_client = None
|
|
117
|
+
_config = None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .config import SDKConfig
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("watchdock_errors")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WatchdockClient:
|
|
17
|
+
"""
|
|
18
|
+
Non-blocking HTTP transport for error events.
|
|
19
|
+
|
|
20
|
+
Events are enqueued and sent by a daemon background thread so the
|
|
21
|
+
calling thread is never delayed by network I/O.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: SDKConfig) -> None:
|
|
25
|
+
self._config = config
|
|
26
|
+
self._queue: queue.Queue[dict | None] = queue.Queue(maxsize=100)
|
|
27
|
+
self._thread = threading.Thread(target=self._worker, daemon=True, name="watchdock-errors")
|
|
28
|
+
self._thread.start()
|
|
29
|
+
|
|
30
|
+
def capture(self, event: dict) -> None:
|
|
31
|
+
"""Enqueue an event for asynchronous delivery. Never blocks or raises."""
|
|
32
|
+
try:
|
|
33
|
+
self._queue.put_nowait(event)
|
|
34
|
+
except queue.Full:
|
|
35
|
+
logger.debug("watchdock_errors: event queue full, dropping event")
|
|
36
|
+
|
|
37
|
+
def flush(self, timeout: float = 2.0) -> None:
|
|
38
|
+
"""Block until the queue is drained or timeout expires."""
|
|
39
|
+
self._queue.join()
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
"""Signal the worker thread to stop after draining the queue."""
|
|
43
|
+
self._queue.put(None)
|
|
44
|
+
self._thread.join(timeout=3.0)
|
|
45
|
+
|
|
46
|
+
def _worker(self) -> None:
|
|
47
|
+
while True:
|
|
48
|
+
try:
|
|
49
|
+
event = self._queue.get(timeout=0.5)
|
|
50
|
+
except queue.Empty:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if event is None:
|
|
54
|
+
self._queue.task_done()
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
self._send(event)
|
|
58
|
+
self._queue.task_done()
|
|
59
|
+
|
|
60
|
+
def _send(self, event: dict) -> None:
|
|
61
|
+
try:
|
|
62
|
+
response = requests.post(
|
|
63
|
+
self._config.ingest_url,
|
|
64
|
+
json=event,
|
|
65
|
+
headers={
|
|
66
|
+
"Authorization": f"Bearer {self._config.api_key}",
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"User-Agent": f"watchdock-errors/{self._config.sdk_version}",
|
|
69
|
+
},
|
|
70
|
+
timeout=self._config.timeout,
|
|
71
|
+
)
|
|
72
|
+
if response.status_code not in (200, 201, 202):
|
|
73
|
+
logger.debug("watchdock_errors: ingest returned %s", response.status_code)
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.debug("watchdock_errors: failed to send event", exc_info=True)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SDKConfig:
|
|
9
|
+
api_key: str
|
|
10
|
+
endpoint: str = "https://api.watchdock.cc"
|
|
11
|
+
environment: str = "production"
|
|
12
|
+
release: str | None = None
|
|
13
|
+
server_name: str | None = None
|
|
14
|
+
send_pii: bool = False
|
|
15
|
+
before_send: Callable[[dict], dict | None] | None = None
|
|
16
|
+
timeout: float = 1.0
|
|
17
|
+
|
|
18
|
+
# Internal — appended to every event payload
|
|
19
|
+
sdk_name: str = field(default="watchdock-errors", init=False, repr=False)
|
|
20
|
+
sdk_version: str = field(default="0.1.0", init=False, repr=False)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def ingest_url(self) -> str:
|
|
24
|
+
base = self.endpoint.rstrip("/")
|
|
25
|
+
return f"{base}/api/v1/error-events/"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from .config import SDKConfig
|
|
7
|
+
from .utils import extract_stacktrace, get_server_info
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("watchdock_errors")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_event(
|
|
13
|
+
exc: BaseException | None,
|
|
14
|
+
config: SDKConfig,
|
|
15
|
+
message: str | None = None,
|
|
16
|
+
request_context: dict | None = None,
|
|
17
|
+
) -> dict | None:
|
|
18
|
+
"""
|
|
19
|
+
Build a Watchdock error event payload.
|
|
20
|
+
|
|
21
|
+
Returns None if the event should be dropped (e.g., before_send returned None).
|
|
22
|
+
"""
|
|
23
|
+
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
24
|
+
|
|
25
|
+
if exc is not None:
|
|
26
|
+
exception_data = {
|
|
27
|
+
"type": type(exc).__name__,
|
|
28
|
+
"message": str(exc),
|
|
29
|
+
"stacktrace": extract_stacktrace(exc),
|
|
30
|
+
}
|
|
31
|
+
title = message or f"{type(exc).__name__}: {str(exc)}"
|
|
32
|
+
else:
|
|
33
|
+
exception_data = {
|
|
34
|
+
"type": "Message",
|
|
35
|
+
"message": message or "",
|
|
36
|
+
"stacktrace": [],
|
|
37
|
+
}
|
|
38
|
+
title = message or ""
|
|
39
|
+
|
|
40
|
+
event: dict = {
|
|
41
|
+
"project_key": config.api_key,
|
|
42
|
+
"timestamp": now,
|
|
43
|
+
"environment": config.environment,
|
|
44
|
+
"title": title,
|
|
45
|
+
"sdk": {
|
|
46
|
+
"name": config.sdk_name,
|
|
47
|
+
"version": config.sdk_version,
|
|
48
|
+
},
|
|
49
|
+
"exception": exception_data,
|
|
50
|
+
"server": get_server_info(),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if config.release:
|
|
54
|
+
event["release"] = config.release
|
|
55
|
+
|
|
56
|
+
if config.server_name:
|
|
57
|
+
event["server"]["server_name"] = config.server_name
|
|
58
|
+
|
|
59
|
+
if request_context:
|
|
60
|
+
if config.send_pii:
|
|
61
|
+
event["request"] = request_context.get("request", {})
|
|
62
|
+
event["user"] = request_context.get("user", {})
|
|
63
|
+
else:
|
|
64
|
+
req = dict(request_context.get("request", {}))
|
|
65
|
+
req.pop("body", None)
|
|
66
|
+
headers = dict(req.get("headers", {}))
|
|
67
|
+
for sensitive in ("Authorization", "Cookie", "X-Api-Key"):
|
|
68
|
+
headers.pop(sensitive, None)
|
|
69
|
+
req["headers"] = headers
|
|
70
|
+
event["request"] = req
|
|
71
|
+
|
|
72
|
+
if config.before_send is not None:
|
|
73
|
+
try:
|
|
74
|
+
event = config.before_send(event)
|
|
75
|
+
except Exception:
|
|
76
|
+
logger.exception("watchdock_errors: before_send hook raised an exception")
|
|
77
|
+
return None
|
|
78
|
+
if event is None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
return event
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django integration for watchdock-errors.
|
|
3
|
+
|
|
4
|
+
Option A — INSTALLED_APPS (auto-registers middleware via AppConfig signal):
|
|
5
|
+
INSTALLED_APPS = [
|
|
6
|
+
...
|
|
7
|
+
"watchdock_errors.integrations.django",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
Option B — MIDDLEWARE (explicit):
|
|
11
|
+
MIDDLEWARE = [
|
|
12
|
+
...
|
|
13
|
+
"watchdock_errors.integrations.django.DjangoErrorMiddleware",
|
|
14
|
+
]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
import watchdock_errors
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from django.http import HttpRequest, HttpResponse
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DjangoErrorMiddleware:
|
|
28
|
+
"""
|
|
29
|
+
Django middleware that captures unhandled exceptions and forwards them
|
|
30
|
+
to Watchdock without interfering with normal Django error handling.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, get_response) -> None:
|
|
34
|
+
self.get_response = get_response
|
|
35
|
+
|
|
36
|
+
def __call__(self, request: "HttpRequest") -> "HttpResponse":
|
|
37
|
+
try:
|
|
38
|
+
response = self.get_response(request)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
watchdock_errors.capture_exception(exc, request_context=_build_request_context(request))
|
|
41
|
+
raise
|
|
42
|
+
return response
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_request_context(request: "HttpRequest") -> dict:
|
|
46
|
+
ctx: dict = {
|
|
47
|
+
"request": {
|
|
48
|
+
"method": request.method,
|
|
49
|
+
"url": request.build_absolute_uri(),
|
|
50
|
+
"headers": dict(request.headers),
|
|
51
|
+
"query_params": dict(request.GET),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if hasattr(request, "user") and request.user and request.user.is_authenticated:
|
|
56
|
+
ctx["user"] = {
|
|
57
|
+
"id": str(request.user.pk),
|
|
58
|
+
"email": getattr(request.user, "email", ""),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return ctx
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# AppConfig for INSTALLED_APPS auto-discovery
|
|
65
|
+
try:
|
|
66
|
+
from django.apps import AppConfig
|
|
67
|
+
|
|
68
|
+
class WatchdockErrorsDjangoConfig(AppConfig):
|
|
69
|
+
name = "watchdock_errors.integrations.django"
|
|
70
|
+
label = "watchdock_errors_django"
|
|
71
|
+
verbose_name = "Watchdock Errors"
|
|
72
|
+
|
|
73
|
+
def ready(self) -> None:
|
|
74
|
+
# Auto-inject middleware when added to INSTALLED_APPS.
|
|
75
|
+
# We patch the middleware list rather than using signals so it works
|
|
76
|
+
# with both sync and async Django.
|
|
77
|
+
from django.conf import settings
|
|
78
|
+
|
|
79
|
+
middleware_path = "watchdock_errors.integrations.django.DjangoErrorMiddleware"
|
|
80
|
+
if middleware_path not in settings.MIDDLEWARE:
|
|
81
|
+
settings.MIDDLEWARE = list(settings.MIDDLEWARE) + [middleware_path]
|
|
82
|
+
|
|
83
|
+
default_app_config = "watchdock_errors.integrations.django.WatchdockErrorsDjangoConfig"
|
|
84
|
+
|
|
85
|
+
except ImportError:
|
|
86
|
+
pass
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI / Starlette integration for watchdock-errors.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from watchdock_errors.integrations.fastapi import setup_watchdock
|
|
6
|
+
|
|
7
|
+
setup_watchdock(app)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import watchdock_errors
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WatchdockASGIMiddleware:
|
|
22
|
+
"""ASGI middleware that captures unhandled exceptions."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, app: "ASGIApp") -> None:
|
|
25
|
+
self.app = app
|
|
26
|
+
|
|
27
|
+
async def __call__(self, scope: "Scope", receive: "Receive", send: "Send") -> None:
|
|
28
|
+
if scope["type"] not in ("http", "websocket"):
|
|
29
|
+
await self.app(scope, receive, send)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
await self.app(scope, receive, send)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
watchdock_errors.capture_exception(exc, request_context=_build_request_context(scope))
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_request_context(scope: dict) -> dict:
|
|
40
|
+
headers = {k.decode(): v.decode() for k, v in scope.get("headers", [])}
|
|
41
|
+
query_string = scope.get("query_string", b"").decode()
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"request": {
|
|
45
|
+
"method": scope.get("method", ""),
|
|
46
|
+
"url": _build_url(scope),
|
|
47
|
+
"headers": headers,
|
|
48
|
+
"query_params": query_string,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_url(scope: dict) -> str:
|
|
54
|
+
scheme = scope.get("scheme", "http")
|
|
55
|
+
server = scope.get("server")
|
|
56
|
+
host = f"{server[0]}:{server[1]}" if server else "localhost"
|
|
57
|
+
path = scope.get("path", "/")
|
|
58
|
+
query = scope.get("query_string", b"").decode()
|
|
59
|
+
return f"{scheme}://{host}{path}{'?' + query if query else ''}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def setup_watchdock(app: "FastAPI") -> None:
|
|
63
|
+
"""
|
|
64
|
+
Install the Watchdock ASGI error-capture middleware on a FastAPI app.
|
|
65
|
+
|
|
66
|
+
Call this after ``watchdock_errors.init()`` and before adding other middleware.
|
|
67
|
+
"""
|
|
68
|
+
app.add_middleware(WatchdockASGIMiddleware)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import linecache
|
|
4
|
+
import platform
|
|
5
|
+
import socket
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_stacktrace(exc: BaseException) -> list[dict]:
|
|
10
|
+
"""Extract structured stack frames from an exception."""
|
|
11
|
+
tb = exc.__traceback__
|
|
12
|
+
if tb is None:
|
|
13
|
+
return []
|
|
14
|
+
|
|
15
|
+
frames = []
|
|
16
|
+
for frame_summary in traceback.extract_tb(tb):
|
|
17
|
+
context_line = linecache.getline(frame_summary.filename, frame_summary.lineno).strip()
|
|
18
|
+
frames.append(
|
|
19
|
+
{
|
|
20
|
+
"filename": frame_summary.filename,
|
|
21
|
+
"function": frame_summary.name,
|
|
22
|
+
"lineno": frame_summary.lineno,
|
|
23
|
+
"context_line": context_line,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
return frames
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_server_info() -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"hostname": socket.gethostname(),
|
|
32
|
+
"python_version": platform.python_version(),
|
|
33
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchdock-errors
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Application error tracking SDK for the Watchdock platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: error-tracking,observability,watchdock
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Provides-Extra: django
|
|
11
|
+
Requires-Dist: django>=3.2; extra == "django"
|
|
12
|
+
Provides-Extra: fastapi
|
|
13
|
+
Requires-Dist: fastapi>=0.95; extra == "fastapi"
|
|
14
|
+
Requires-Dist: starlette>=0.27; extra == "fastapi"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
18
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# watchdock-errors
|
|
21
|
+
|
|
22
|
+
Python SDK for application-level error tracking on the [Watchdock](https://watchdock.cc) platform.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install watchdock-errors
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With framework extras:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install "watchdock-errors[django]"
|
|
34
|
+
pip install "watchdock-errors[fastapi]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import watchdock_errors
|
|
41
|
+
|
|
42
|
+
watchdock_errors.init(
|
|
43
|
+
api_key="wdk_xxx",
|
|
44
|
+
environment="production",
|
|
45
|
+
release="1.0.0",
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Django
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# settings.py
|
|
53
|
+
MIDDLEWARE = [
|
|
54
|
+
...
|
|
55
|
+
"watchdock_errors.integrations.django.DjangoErrorMiddleware",
|
|
56
|
+
]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or via `INSTALLED_APPS` for automatic registration:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
INSTALLED_APPS = [
|
|
63
|
+
...
|
|
64
|
+
"watchdock_errors.integrations.django",
|
|
65
|
+
]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### FastAPI
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from watchdock_errors.integrations.fastapi import setup_watchdock
|
|
72
|
+
|
|
73
|
+
setup_watchdock(app)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Manual capture
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Capture the current exception
|
|
80
|
+
try:
|
|
81
|
+
process_payment()
|
|
82
|
+
except Exception:
|
|
83
|
+
watchdock_errors.capture_exception()
|
|
84
|
+
|
|
85
|
+
# Capture a specific exception
|
|
86
|
+
watchdock_errors.capture_exception(exc)
|
|
87
|
+
|
|
88
|
+
# Capture a message
|
|
89
|
+
watchdock_errors.capture_message("Stripe webhook signature invalid")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## PII scrubbing
|
|
93
|
+
|
|
94
|
+
By default, `Authorization`, `Cookie`, and `X-Api-Key` headers are stripped and the request body is not sent. Set `send_pii=True` to disable scrubbing.
|
|
95
|
+
|
|
96
|
+
Use the `before_send` hook for custom scrubbing:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
def scrub(event):
|
|
100
|
+
event["request"]["headers"].pop("X-Internal-Token", None)
|
|
101
|
+
return event # return None to drop the event entirely
|
|
102
|
+
|
|
103
|
+
watchdock_errors.init(api_key="wdk_xxx", before_send=scrub)
|
|
104
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/watchdock_errors/__init__.py
|
|
4
|
+
src/watchdock_errors/client.py
|
|
5
|
+
src/watchdock_errors/config.py
|
|
6
|
+
src/watchdock_errors/event.py
|
|
7
|
+
src/watchdock_errors/utils.py
|
|
8
|
+
src/watchdock_errors.egg-info/PKG-INFO
|
|
9
|
+
src/watchdock_errors.egg-info/SOURCES.txt
|
|
10
|
+
src/watchdock_errors.egg-info/dependency_links.txt
|
|
11
|
+
src/watchdock_errors.egg-info/requires.txt
|
|
12
|
+
src/watchdock_errors.egg-info/top_level.txt
|
|
13
|
+
src/watchdock_errors/integrations/__init__.py
|
|
14
|
+
src/watchdock_errors/integrations/django.py
|
|
15
|
+
src/watchdock_errors/integrations/fastapi.py
|
|
16
|
+
tests/test_client.py
|
|
17
|
+
tests/test_event.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
watchdock_errors
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import responses as rsps_lib
|
|
5
|
+
|
|
6
|
+
from watchdock_errors.client import WatchdockClient
|
|
7
|
+
from watchdock_errors.config import SDKConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture()
|
|
11
|
+
def config():
|
|
12
|
+
return SDKConfig(api_key="wdk_test", endpoint="https://api.watchdock.cc", timeout=1.0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@rsps_lib.activate
|
|
16
|
+
def test_client_sends_event(config):
|
|
17
|
+
rsps_lib.add(rsps_lib.POST, config.ingest_url, status=202)
|
|
18
|
+
|
|
19
|
+
client = WatchdockClient(config)
|
|
20
|
+
client.capture({"exception": {"type": "ValueError", "message": "test"}})
|
|
21
|
+
client.flush()
|
|
22
|
+
client.close()
|
|
23
|
+
|
|
24
|
+
assert len(rsps_lib.calls) == 1
|
|
25
|
+
assert rsps_lib.calls[0].request.headers["Authorization"] == "Bearer wdk_test"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@rsps_lib.activate
|
|
29
|
+
def test_client_does_not_raise_on_network_error(config):
|
|
30
|
+
rsps_lib.add(rsps_lib.POST, config.ingest_url, body=ConnectionError("timeout"))
|
|
31
|
+
|
|
32
|
+
client = WatchdockClient(config)
|
|
33
|
+
client.capture({"exception": {"type": "ValueError", "message": "test"}})
|
|
34
|
+
client.flush()
|
|
35
|
+
client.close()
|
|
36
|
+
# No exception raised — SDK is silent on transport failures
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_client_drops_events_when_queue_full(config):
|
|
40
|
+
client = WatchdockClient(config)
|
|
41
|
+
# Fill the queue beyond maxsize
|
|
42
|
+
for _ in range(200):
|
|
43
|
+
client.capture({"exception": {"type": "X"}})
|
|
44
|
+
# Should not raise
|
|
45
|
+
client.close()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from watchdock_errors.config import SDKConfig
|
|
3
|
+
from watchdock_errors.event import build_event
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture()
|
|
7
|
+
def config():
|
|
8
|
+
return SDKConfig(api_key="wdk_test")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_build_event_from_exception(config):
|
|
12
|
+
try:
|
|
13
|
+
raise ValueError("bad price")
|
|
14
|
+
except ValueError as exc:
|
|
15
|
+
event = build_event(exc, config)
|
|
16
|
+
|
|
17
|
+
assert event is not None
|
|
18
|
+
assert event["exception"]["type"] == "ValueError"
|
|
19
|
+
assert event["exception"]["message"] == "bad price"
|
|
20
|
+
assert isinstance(event["exception"]["stacktrace"], list)
|
|
21
|
+
assert len(event["exception"]["stacktrace"]) > 0
|
|
22
|
+
assert event["project_key"] == "wdk_test"
|
|
23
|
+
assert "timestamp" in event
|
|
24
|
+
assert event["sdk"]["name"] == "watchdock-errors"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_build_event_from_message(config):
|
|
28
|
+
event = build_event(None, config, message="Stripe webhook failed")
|
|
29
|
+
|
|
30
|
+
assert event is not None
|
|
31
|
+
assert event["exception"]["message"] == "Stripe webhook failed"
|
|
32
|
+
assert event["exception"]["type"] == "Message"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_before_send_can_drop_event(config):
|
|
36
|
+
config.before_send = lambda e: None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
raise RuntimeError("oops")
|
|
40
|
+
except RuntimeError as exc:
|
|
41
|
+
event = build_event(exc, config)
|
|
42
|
+
|
|
43
|
+
assert event is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_before_send_can_mutate_event(config):
|
|
47
|
+
def add_tag(event):
|
|
48
|
+
event["tags"] = {"team": "backend"}
|
|
49
|
+
return event
|
|
50
|
+
|
|
51
|
+
config.before_send = add_tag
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
raise RuntimeError("oops")
|
|
55
|
+
except RuntimeError as exc:
|
|
56
|
+
event = build_event(exc, config)
|
|
57
|
+
|
|
58
|
+
assert event is not None
|
|
59
|
+
assert event["tags"] == {"team": "backend"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_pii_headers_scrubbed_by_default(config):
|
|
63
|
+
req_ctx = {
|
|
64
|
+
"request": {
|
|
65
|
+
"method": "POST",
|
|
66
|
+
"url": "/checkout/",
|
|
67
|
+
"headers": {"Authorization": "Bearer secret", "Content-Type": "application/json"},
|
|
68
|
+
"query_params": {},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
raise ValueError("x")
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
event = build_event(exc, config, request_context=req_ctx)
|
|
76
|
+
|
|
77
|
+
assert "Authorization" not in event["request"]["headers"]
|
|
78
|
+
assert event["request"]["headers"]["Content-Type"] == "application/json"
|