watchdock-errors 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,12 @@
1
+ watchdock_errors/__init__.py,sha256=zEgpAJ1FiiPoTUEkFTMN_QczkziUIvnGz9PcH6cLmjw,3229
2
+ watchdock_errors/client.py,sha256=CkuNEIpjKU6TLp8dauShuT3DM1iuxMHiFkO8PE5hhyM,2485
3
+ watchdock_errors/config.py,sha256=Ownx85BnuN-jdzlNiBqjUWKMiSnjC1gIkZxoanTxOHI,774
4
+ watchdock_errors/event.py,sha256=sagnYKrqNQSAYoWs6_KBH5h0mD45CIRyuFGBY2ceTZA,2405
5
+ watchdock_errors/utils.py,sha256=upQCS9KSbmJo32emhEDduu2lKceJBcSBTEJDgQ-v2A0,901
6
+ watchdock_errors/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ watchdock_errors/integrations/django.py,sha256=yjq0oSLScRe2kr2G3V1RF7tM6tzBe8X1nL0rwsSjYjk,2644
8
+ watchdock_errors/integrations/fastapi.py,sha256=Nf2RMtQV-inDMDsARYdXXQAAvy_65W-lhX9V6WFqahE,2031
9
+ watchdock_errors-0.1.0.dist-info/METADATA,sha256=AzXEK2uaAAB02yFS6YQn9ji2stxCoRCM_FWMFsfV6d4,2326
10
+ watchdock_errors-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
11
+ watchdock_errors-0.1.0.dist-info/top_level.txt,sha256=3HlgohNEtJG4IQb8p5jLkK046xW47MeIR-RU8N95H0M,17
12
+ watchdock_errors-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ watchdock_errors