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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
@@ -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,13 @@
1
+ requests>=2.28
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-mock>=3.0
6
+ responses>=0.23
7
+
8
+ [django]
9
+ django>=3.2
10
+
11
+ [fastapi]
12
+ fastapi>=0.95
13
+ starlette>=0.27
@@ -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"