errorgap 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.
errorgap/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from .client import Client, DeliveryResult
6
+ from .configuration import Configuration
7
+ from .handlers import install_excepthook, uninstall_excepthook
8
+ from .version import VERSION
9
+
10
+ __all__ = [
11
+ "init",
12
+ "notify",
13
+ "flush",
14
+ "shutdown",
15
+ "configuration",
16
+ "client",
17
+ "Configuration",
18
+ "Client",
19
+ "DeliveryResult",
20
+ "VERSION",
21
+ ]
22
+
23
+ _configuration = Configuration()
24
+ _client = Client(_configuration)
25
+
26
+
27
+ def init(
28
+ *,
29
+ endpoint: Optional[str] = None,
30
+ project_slug: Optional[str] = None,
31
+ project_id: Optional[str] = None,
32
+ api_key: Optional[str] = None,
33
+ environment: Optional[str] = None,
34
+ root_directory: Optional[str] = None,
35
+ async_: Optional[bool] = None,
36
+ filter_keys: Optional[tuple] = None,
37
+ logger: Optional[Any] = None,
38
+ capture_globals: bool = True,
39
+ ) -> None:
40
+ """Configure the SDK and install global error hooks.
41
+
42
+ Reads ``ERRORGAP_ENDPOINT``, ``ERRORGAP_PROJECT_SLUG``,
43
+ ``ERRORGAP_PROJECT_ID``, and ``ERRORGAP_API_KEY`` from the environment when
44
+ the matching keyword argument is omitted.
45
+ """
46
+ global _configuration, _client
47
+
48
+ overrides: Dict[str, Any] = {}
49
+ if endpoint is not None:
50
+ overrides["endpoint"] = endpoint
51
+ if project_slug is not None:
52
+ overrides["project_slug"] = project_slug
53
+ if project_id is not None:
54
+ overrides["project_id"] = project_id
55
+ if api_key is not None:
56
+ overrides["api_key"] = api_key
57
+ if environment is not None:
58
+ overrides["environment"] = environment
59
+ if root_directory is not None:
60
+ overrides["root_directory"] = root_directory
61
+ if async_ is not None:
62
+ overrides["async_"] = async_
63
+ if filter_keys is not None:
64
+ overrides["filter_keys"] = filter_keys
65
+ if logger is not None:
66
+ overrides["logger"] = logger
67
+
68
+ _configuration = Configuration(**overrides)
69
+ _client.configure(_configuration)
70
+
71
+ if capture_globals:
72
+ install_excepthook(_client)
73
+ else:
74
+ uninstall_excepthook()
75
+
76
+
77
+ def notify(
78
+ exc: BaseException,
79
+ context: Optional[Dict[str, Any]] = None,
80
+ environment: Optional[Dict[str, Any]] = None,
81
+ session: Optional[Dict[str, Any]] = None,
82
+ params: Optional[Dict[str, Any]] = None,
83
+ sync: bool = False,
84
+ ) -> DeliveryResult:
85
+ return _client.notify(
86
+ exc,
87
+ context=context,
88
+ environment=environment,
89
+ session=session,
90
+ params=params,
91
+ sync=sync,
92
+ )
93
+
94
+
95
+ def flush(timeout: Optional[float] = None) -> None:
96
+ _client.flush(timeout)
97
+
98
+
99
+ def shutdown(timeout: Optional[float] = None) -> None:
100
+ _client.shutdown(timeout)
101
+
102
+
103
+ def configuration() -> Configuration:
104
+ return _configuration
105
+
106
+
107
+ def client() -> Client:
108
+ return _client
errorgap/backtrace.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import traceback
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ def parse_backtrace(exc: BaseException, root_directory: str) -> List[Dict[str, Any]]:
9
+ frames: List[Dict[str, Any]] = []
10
+ tb_frames = traceback.extract_tb(exc.__traceback__)
11
+
12
+ for index, frame in enumerate(tb_frames):
13
+ file = frame.filename
14
+ frames.append(
15
+ {
16
+ "file": _relative_file(file, root_directory),
17
+ "line": frame.lineno,
18
+ "function": frame.name,
19
+ "in_app": _is_in_app(file, root_directory),
20
+ "index": index,
21
+ }
22
+ )
23
+
24
+ return frames
25
+
26
+
27
+ def _relative_file(file: Optional[str], root: str) -> Optional[str]:
28
+ if not file:
29
+ return file
30
+ if not root:
31
+ return file
32
+ normalized = root if root.endswith(os.sep) else root + os.sep
33
+ if file.startswith(normalized):
34
+ return file[len(normalized):]
35
+ return file
36
+
37
+
38
+ def _is_in_app(file: Optional[str], root: str) -> bool:
39
+ if not file or not root:
40
+ return False
41
+ if "site-packages" in file:
42
+ return False
43
+ if file.startswith("<"):
44
+ return False
45
+ return file.startswith(root)
errorgap/client.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import queue
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Optional
9
+ from urllib import error as urlerror
10
+ from urllib import request as urlrequest
11
+
12
+ from .configuration import Configuration
13
+ from .notice import build_notice
14
+ from .version import VERSION
15
+
16
+
17
+ @dataclass
18
+ class DeliveryResult:
19
+ status: Optional[int] = None
20
+ body: Optional[str] = None
21
+ error: Optional[BaseException] = None
22
+ queued: bool = False
23
+
24
+ @property
25
+ def success(self) -> bool:
26
+ return self.error is None and self.status is not None and 200 <= self.status < 300
27
+
28
+
29
+ class Client:
30
+ def __init__(self, configuration: Configuration):
31
+ self._configuration = configuration
32
+ self._queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue()
33
+ self._worker: Optional[threading.Thread] = None
34
+ self._lock = threading.Lock()
35
+
36
+ def configure(self, configuration: Configuration) -> None:
37
+ self._configuration = configuration
38
+
39
+ @property
40
+ def configuration(self) -> Configuration:
41
+ return self._configuration
42
+
43
+ def notify(
44
+ self,
45
+ exc: BaseException,
46
+ context: Optional[Dict[str, Any]] = None,
47
+ environment: Optional[Dict[str, Any]] = None,
48
+ session: Optional[Dict[str, Any]] = None,
49
+ params: Optional[Dict[str, Any]] = None,
50
+ sync: bool = False,
51
+ ) -> DeliveryResult:
52
+ try:
53
+ self._configuration.validate()
54
+ notice = build_notice(
55
+ exc,
56
+ self._configuration,
57
+ context=context,
58
+ environment=environment,
59
+ session=session,
60
+ params=params,
61
+ )
62
+ except Exception as caught: # noqa: BLE001 — SDK must not raise
63
+ self._log(caught)
64
+ return DeliveryResult(error=caught)
65
+
66
+ if sync or not self._configuration.async_:
67
+ return self.deliver(notice)
68
+
69
+ self._ensure_worker()
70
+ self._queue.put(notice)
71
+ return DeliveryResult(queued=True, status=202)
72
+
73
+ def deliver(self, notice: Dict[str, Any]) -> DeliveryResult:
74
+ url = _notices_url(self._configuration)
75
+ body = json.dumps(notice).encode("utf-8")
76
+ headers = {
77
+ "Content-Type": "application/json",
78
+ "User-Agent": f"errorgap-python/{VERSION}",
79
+ }
80
+ if self._configuration.api_key:
81
+ headers["X-Errorgap-Project-Key"] = self._configuration.api_key
82
+
83
+ req = urlrequest.Request(url, data=body, method="POST", headers=headers)
84
+ try:
85
+ with urlrequest.urlopen(req, timeout=10) as response:
86
+ response_body = response.read().decode("utf-8", errors="replace")
87
+ return DeliveryResult(status=response.status, body=response_body)
88
+ except urlerror.HTTPError as http_err:
89
+ response_body = ""
90
+ try:
91
+ response_body = http_err.read().decode("utf-8", errors="replace")
92
+ except Exception: # noqa: BLE001
93
+ pass
94
+ return DeliveryResult(status=http_err.code, body=response_body, error=http_err)
95
+ except Exception as caught: # noqa: BLE001 — network/socket errors
96
+ self._log(caught)
97
+ return DeliveryResult(error=caught)
98
+
99
+ def flush(self, timeout: Optional[float] = None) -> None:
100
+ """Block until all queued notices have been delivered."""
101
+ with self._lock:
102
+ worker = self._worker
103
+ if worker is None:
104
+ return
105
+ self._queue.join() if timeout is None else _wait_join(self._queue, timeout)
106
+
107
+ def shutdown(self, timeout: Optional[float] = None) -> None:
108
+ """Flush and tear down the worker thread. Safe to call multiple times."""
109
+ with self._lock:
110
+ worker = self._worker
111
+ self._worker = None
112
+ if worker is None:
113
+ return
114
+ self.flush(timeout)
115
+ self._queue.put(None)
116
+ worker.join(timeout)
117
+
118
+ def _ensure_worker(self) -> None:
119
+ with self._lock:
120
+ if self._worker is not None and self._worker.is_alive():
121
+ return
122
+ self._worker = threading.Thread(
123
+ target=self._worker_loop, name="errorgap-delivery", daemon=True
124
+ )
125
+ self._worker.start()
126
+
127
+ def _worker_loop(self) -> None:
128
+ while True:
129
+ item = self._queue.get()
130
+ try:
131
+ if item is None:
132
+ return
133
+ self.deliver(item)
134
+ finally:
135
+ self._queue.task_done()
136
+
137
+ def _log(self, exc: BaseException) -> None:
138
+ logger = self._configuration.get_logger()
139
+ if logger is None:
140
+ return
141
+ if isinstance(logger, logging.Logger):
142
+ logger.warning("[errorgap] %s: %s", exc.__class__.__name__, exc)
143
+ else:
144
+ try:
145
+ logger.warning(f"[errorgap] {exc.__class__.__name__}: {exc}")
146
+ except Exception: # noqa: BLE001
147
+ pass
148
+
149
+
150
+ def _notices_url(configuration: Configuration) -> str:
151
+ base = configuration.endpoint.rstrip("/")
152
+ return f"{base}/api/projects/{configuration.project_slug}/notices"
153
+
154
+
155
+ def _wait_join(q: "queue.Queue[Any]", timeout: float) -> None:
156
+ """queue.Queue has no join-with-timeout. Poll for emptiness."""
157
+ import time
158
+
159
+ deadline = time.monotonic() + timeout
160
+ while q.unfinished_tasks > 0 and time.monotonic() < deadline:
161
+ time.sleep(0.01)
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+
8
+ DEFAULT_FILTER_KEYS = (
9
+ "password",
10
+ "password_confirmation",
11
+ "token",
12
+ "secret",
13
+ "api_key",
14
+ "authorization",
15
+ "cookie",
16
+ )
17
+
18
+
19
+ def _default_environment() -> str:
20
+ return os.environ.get("ERRORGAP_ENVIRONMENT") or os.environ.get("ENV") or "development"
21
+
22
+
23
+ @dataclass
24
+ class Configuration:
25
+ endpoint: str = field(
26
+ default_factory=lambda: os.environ.get("ERRORGAP_ENDPOINT", "http://127.0.0.1:3030")
27
+ )
28
+ project_slug: Optional[str] = field(
29
+ default_factory=lambda: os.environ.get("ERRORGAP_PROJECT_SLUG")
30
+ )
31
+ project_id: Optional[str] = field(
32
+ default_factory=lambda: os.environ.get("ERRORGAP_PROJECT_ID")
33
+ )
34
+ api_key: Optional[str] = field(default_factory=lambda: os.environ.get("ERRORGAP_API_KEY"))
35
+ environment: str = field(default_factory=_default_environment)
36
+ root_directory: str = field(default_factory=os.getcwd)
37
+ async_: bool = True
38
+ logger: Optional[logging.Logger] = None
39
+ filter_keys: tuple = DEFAULT_FILTER_KEYS
40
+ apm_enabled: bool = False
41
+ apm_sample_rate: float = 1.0
42
+
43
+ def validate(self) -> None:
44
+ if not self.project_slug or not str(self.project_slug).strip():
45
+ raise ValueError("Errorgap project_slug is required")
46
+
47
+ def get_logger(self) -> logging.Logger:
48
+ return self.logger or logging.getLogger("errorgap")
errorgap/django.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ from . import notify
6
+
7
+
8
+ class ErrorgapMiddleware:
9
+ """Django middleware that reports unhandled exceptions to Errorgap.
10
+
11
+ Add ``"errorgap.django.ErrorgapMiddleware"`` to ``MIDDLEWARE``. Place it
12
+ early so it sees exceptions raised by inner middleware too.
13
+ """
14
+
15
+ def __init__(self, get_response: Callable[..., Any]):
16
+ self.get_response = get_response
17
+
18
+ def __call__(self, request: Any) -> Any:
19
+ return self.get_response(request)
20
+
21
+ def process_exception(self, request: Any, exception: BaseException) -> Optional[Any]:
22
+ notify(
23
+ exception,
24
+ sync=True,
25
+ context=_context(request),
26
+ environment=_environment(request),
27
+ session=_session(request),
28
+ params=_params(request),
29
+ )
30
+ # Returning None lets Django continue its exception handling chain.
31
+ return None
32
+
33
+
34
+ def _context(request: Any) -> Dict[str, Any]:
35
+ try:
36
+ resolver = getattr(request, "resolver_match", None)
37
+ view_name = resolver.view_name if resolver else None
38
+ except Exception: # noqa: BLE001
39
+ view_name = None
40
+ try:
41
+ url = request.build_absolute_uri()
42
+ except Exception: # noqa: BLE001
43
+ url = getattr(request, "path", None)
44
+ return {
45
+ "url": url,
46
+ "component": view_name or getattr(request, "path", None),
47
+ "action": getattr(request, "method", None),
48
+ }
49
+
50
+
51
+ def _environment(request: Any) -> Dict[str, Any]:
52
+ meta = getattr(request, "META", {}) or {}
53
+ return {
54
+ "method": getattr(request, "method", None),
55
+ "path": getattr(request, "path", None),
56
+ "query_string": meta.get("QUERY_STRING"),
57
+ "user_agent": meta.get("HTTP_USER_AGENT"),
58
+ "remote_addr": meta.get("REMOTE_ADDR"),
59
+ }
60
+
61
+
62
+ def _session(request: Any) -> Dict[str, Any]:
63
+ try:
64
+ session = getattr(request, "session", None)
65
+ if session is None:
66
+ return {}
67
+ return dict(session.items())
68
+ except Exception: # noqa: BLE001
69
+ return {}
70
+
71
+
72
+ def _params(request: Any) -> Dict[str, Any]:
73
+ out: Dict[str, Any] = {}
74
+ try:
75
+ for key, value in (getattr(request, "GET", {}) or {}).items():
76
+ out[key] = value
77
+ post = getattr(request, "POST", None)
78
+ if post:
79
+ for key, value in post.items():
80
+ out[key] = value
81
+ except Exception: # noqa: BLE001
82
+ return out
83
+ return out
errorgap/fastapi.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict
4
+
5
+ from . import notify
6
+
7
+
8
+ class ErrorgapMiddleware:
9
+ """Pure ASGI middleware that reports unhandled exceptions to Errorgap.
10
+
11
+ Usage::
12
+
13
+ from fastapi import FastAPI
14
+ from errorgap.fastapi import ErrorgapMiddleware
15
+
16
+ app = FastAPI()
17
+ app.add_middleware(ErrorgapMiddleware)
18
+ """
19
+
20
+ def __init__(self, app: Callable[..., Any]) -> None:
21
+ self.app = app
22
+
23
+ async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None:
24
+ if scope.get("type") != "http":
25
+ await self.app(scope, receive, send)
26
+ return
27
+
28
+ try:
29
+ await self.app(scope, receive, send)
30
+ except Exception as exc:
31
+ notify(
32
+ exc,
33
+ sync=True,
34
+ context=_context(scope),
35
+ environment=_environment(scope),
36
+ )
37
+ raise
38
+
39
+
40
+ def _context(scope: Dict[str, Any]) -> Dict[str, Any]:
41
+ return {
42
+ "url": _build_url(scope),
43
+ "component": scope.get("path"),
44
+ "action": scope.get("method"),
45
+ }
46
+
47
+
48
+ def _environment(scope: Dict[str, Any]) -> Dict[str, Any]:
49
+ headers = {k.decode("latin1"): v.decode("latin1") for k, v in scope.get("headers", [])}
50
+ client = scope.get("client") or (None, None)
51
+ return {
52
+ "method": scope.get("method"),
53
+ "path": scope.get("path"),
54
+ "query_string": scope.get("query_string", b"").decode("utf-8", errors="replace"),
55
+ "user_agent": headers.get("user-agent"),
56
+ "remote_addr": client[0],
57
+ }
58
+
59
+
60
+ def _build_url(scope: Dict[str, Any]) -> str:
61
+ scheme = scope.get("scheme", "http")
62
+ headers = dict(scope.get("headers", []))
63
+ host = headers.get(b"host", b"").decode("latin1") or "localhost"
64
+ path = scope.get("path", "/")
65
+ query = scope.get("query_string", b"").decode("utf-8", errors="replace")
66
+ suffix = f"?{query}" if query else ""
67
+ return f"{scheme}://{host}{path}{suffix}"
errorgap/filter.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterable
4
+
5
+ FILTERED = "[FILTERED]"
6
+
7
+
8
+ def filter_params(params: Dict[str, Any], filter_keys: Iterable[str]) -> Dict[str, Any]:
9
+ lowered = tuple(k.lower() for k in filter_keys)
10
+ return _walk(params, lowered)
11
+
12
+
13
+ def _walk(value: Dict[str, Any], lowered: tuple) -> Dict[str, Any]:
14
+ out: Dict[str, Any] = {}
15
+ for key, val in value.items():
16
+ if _is_sensitive(str(key), lowered):
17
+ out[key] = FILTERED
18
+ elif isinstance(val, dict):
19
+ out[key] = _walk(val, lowered)
20
+ else:
21
+ out[key] = val
22
+ return out
23
+
24
+
25
+ def _is_sensitive(key: str, lowered: tuple) -> bool:
26
+ lk = key.lower()
27
+ return any(needle in lk for needle in lowered)
errorgap/flask.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict
4
+
5
+ from . import notify
6
+
7
+
8
+ def init_app(app: Any) -> None:
9
+ """Register Errorgap with a Flask application.
10
+
11
+ Subscribes to Flask's ``got_request_exception`` signal so all unhandled
12
+ exceptions raised inside a request are reported.
13
+ """
14
+ try:
15
+ from flask import got_request_exception
16
+ except ImportError as exc: # pragma: no cover
17
+ raise RuntimeError("Flask is required to use errorgap.flask.init_app") from exc
18
+
19
+ def _on_exception(sender: Any, exception: BaseException, **_: Any) -> None:
20
+ try:
21
+ from flask import request
22
+
23
+ ctx = _context(request)
24
+ env = _environment(request)
25
+ params = _params(request)
26
+ except Exception: # noqa: BLE001
27
+ ctx, env, params = {}, {}, {}
28
+
29
+ notify(
30
+ exception,
31
+ sync=True,
32
+ context=ctx,
33
+ environment=env,
34
+ params=params,
35
+ )
36
+
37
+ # weak=False: keep a strong reference so the local closure isn't GC'd.
38
+ got_request_exception.connect(_on_exception, app, weak=False)
39
+
40
+
41
+ def _context(request: Any) -> Dict[str, Any]:
42
+ return {
43
+ "url": getattr(request, "url", None),
44
+ "component": getattr(request, "endpoint", None) or getattr(request, "path", None),
45
+ "action": getattr(request, "method", None),
46
+ }
47
+
48
+
49
+ def _environment(request: Any) -> Dict[str, Any]:
50
+ return {
51
+ "method": getattr(request, "method", None),
52
+ "path": getattr(request, "path", None),
53
+ "query_string": getattr(request, "query_string", b"").decode("utf-8", errors="replace")
54
+ if isinstance(getattr(request, "query_string", None), (bytes, bytearray))
55
+ else getattr(request, "query_string", None),
56
+ "user_agent": str(getattr(request, "user_agent", "") or ""),
57
+ "remote_addr": getattr(request, "remote_addr", None),
58
+ }
59
+
60
+
61
+ def _params(request: Any) -> Dict[str, Any]:
62
+ out: Dict[str, Any] = {}
63
+ try:
64
+ for key, value in (getattr(request, "args", {}) or {}).items():
65
+ out[key] = value
66
+ except Exception: # noqa: BLE001
67
+ pass
68
+ try:
69
+ form = getattr(request, "form", None)
70
+ if form:
71
+ for key, value in form.items():
72
+ out[key] = value
73
+ except Exception: # noqa: BLE001
74
+ pass
75
+ try:
76
+ if request.is_json:
77
+ json_body = request.get_json(silent=True)
78
+ if isinstance(json_body, dict):
79
+ for key, value in json_body.items():
80
+ out[key] = value
81
+ except Exception: # noqa: BLE001
82
+ pass
83
+ return out
errorgap/handlers.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from types import TracebackType
5
+ from typing import Optional, Type
6
+
7
+ from .client import Client
8
+
9
+ _installed = False
10
+ _previous_excepthook = None
11
+
12
+
13
+ def install_excepthook(client: Client) -> None:
14
+ global _installed, _previous_excepthook
15
+ if _installed:
16
+ return
17
+ _previous_excepthook = sys.excepthook
18
+ _installed = True
19
+
20
+ def _hook(
21
+ exc_type: Type[BaseException],
22
+ exc: BaseException,
23
+ tb: Optional[TracebackType],
24
+ ) -> None:
25
+ try:
26
+ exc.__traceback__ = tb
27
+ client.notify(exc, sync=True, context={"source": "sys.excepthook"})
28
+ finally:
29
+ if _previous_excepthook is not None:
30
+ _previous_excepthook(exc_type, exc, tb)
31
+
32
+ sys.excepthook = _hook
33
+
34
+
35
+ def uninstall_excepthook() -> None:
36
+ global _installed, _previous_excepthook
37
+ if not _installed:
38
+ return
39
+ sys.excepthook = _previous_excepthook or sys.__excepthook__
40
+ _previous_excepthook = None
41
+ _installed = False
errorgap/notice.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Dict, Optional
5
+
6
+ from .backtrace import parse_backtrace
7
+ from .configuration import Configuration
8
+ from .filter import filter_params
9
+ from .version import VERSION
10
+
11
+
12
+ def build_notice(
13
+ exc: BaseException,
14
+ configuration: Configuration,
15
+ context: Optional[Dict[str, Any]] = None,
16
+ environment: Optional[Dict[str, Any]] = None,
17
+ session: Optional[Dict[str, Any]] = None,
18
+ params: Optional[Dict[str, Any]] = None,
19
+ ) -> Dict[str, Any]:
20
+ default_context: Dict[str, Any] = {
21
+ "notifier": "errorgap-python",
22
+ "notifier_version": VERSION,
23
+ "environment": configuration.environment,
24
+ "root_directory": configuration.root_directory,
25
+ }
26
+ if context:
27
+ default_context.update(context)
28
+
29
+ return {
30
+ "project_id": configuration.project_id,
31
+ "received_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
32
+ "errors": [
33
+ {
34
+ "type": _error_type(exc),
35
+ "message": str(exc),
36
+ "backtrace": parse_backtrace(exc, configuration.root_directory),
37
+ }
38
+ ],
39
+ "context": default_context,
40
+ "environment": environment or {},
41
+ "session": session or {},
42
+ "params": filter_params(params or {}, configuration.filter_keys),
43
+ }
44
+
45
+
46
+ def _error_type(exc: BaseException) -> str:
47
+ return exc.__class__.__name__
errorgap/version.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "0.1.0"
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: errorgap
3
+ Version: 0.1.0
4
+ Summary: Python notifier for Errorgap error tracking.
5
+ Project-URL: Homepage, https://github.com/errorgaphq/errorgap-python
6
+ Project-URL: Source, https://github.com/errorgaphq/errorgap-python
7
+ Author: Errorgap
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: error-tracking,errorgap,exceptions,monitoring
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Bug Tracking
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: dev
22
+ Requires-Dist: django>=4.2; extra == 'dev'
23
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
24
+ Requires-Dist: flask>=2.3; extra == 'dev'
25
+ Requires-Dist: httpx>=0.27; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Requires-Dist: starlette>=0.27; extra == 'dev'
28
+ Provides-Extra: django
29
+ Requires-Dist: django>=4.2; extra == 'django'
30
+ Provides-Extra: fastapi
31
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
32
+ Requires-Dist: starlette>=0.27; extra == 'fastapi'
33
+ Provides-Extra: flask
34
+ Requires-Dist: flask>=2.3; extra == 'flask'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # errorgap
38
+
39
+ Python notifier for [Errorgap](https://errorgap.com). Captures exceptions,
40
+ normalizes tracebacks, and ships notices to an Errorgap server. Single
41
+ package covers plain Python, Django, Flask, and FastAPI.
42
+
43
+ ## Install
44
+
45
+ ```sh
46
+ pip install errorgap
47
+ ```
48
+
49
+ For framework integrations:
50
+
51
+ ```sh
52
+ pip install "errorgap[django]" # or [flask], [fastapi]
53
+ ```
54
+
55
+ Requires Python 3.9+.
56
+
57
+ ## Configure
58
+
59
+ ```python
60
+ import errorgap
61
+
62
+ errorgap.init(
63
+ endpoint=...,
64
+ project_slug=...,
65
+ api_key=...,
66
+ environment="production",
67
+ )
68
+ ```
69
+
70
+ `init` reads the same values from `ERRORGAP_ENDPOINT`,
71
+ `ERRORGAP_PROJECT_SLUG`, `ERRORGAP_PROJECT_ID`, and `ERRORGAP_API_KEY` if you
72
+ don't pass them. By default it installs a `sys.excepthook` to catch
73
+ uncaught exceptions in scripts; pass `capture_globals=False` to skip.
74
+
75
+ ## Manual notification
76
+
77
+ ```python
78
+ try:
79
+ risky()
80
+ except Exception as exc:
81
+ errorgap.notify(exc, context={"component": "billing"})
82
+ raise
83
+ ```
84
+
85
+ `notify` returns a `DeliveryResult` (`status`, `body`, `error`, `queued`).
86
+ The SDK never raises.
87
+
88
+ ## Django
89
+
90
+ Add to `MIDDLEWARE`:
91
+
92
+ ```python
93
+ MIDDLEWARE = [
94
+ "errorgap.django.ErrorgapMiddleware",
95
+ # ... your existing middleware ...
96
+ ]
97
+ ```
98
+
99
+ ## Flask
100
+
101
+ ```python
102
+ from flask import Flask
103
+ from errorgap.flask import init_app
104
+
105
+ app = Flask(__name__)
106
+ init_app(app)
107
+ ```
108
+
109
+ ## FastAPI
110
+
111
+ ```python
112
+ from fastapi import FastAPI
113
+ from errorgap.fastapi import ErrorgapMiddleware
114
+
115
+ app = FastAPI()
116
+ app.add_middleware(ErrorgapMiddleware)
117
+ ```
118
+
119
+ ## Configuration reference
120
+
121
+ | Argument | Default | Notes |
122
+ |---|---|---|
123
+ | `endpoint` | `ERRORGAP_ENDPOINT` or `http://127.0.0.1:3030` | Base URL, no trailing slash |
124
+ | `project_slug` | `ERRORGAP_PROJECT_SLUG` | **Required** |
125
+ | `project_id` | `ERRORGAP_PROJECT_ID` | Optional, embedded in payload |
126
+ | `api_key` | `ERRORGAP_API_KEY` | Sent as `x-errorgap-project-key` |
127
+ | `environment` | `ERRORGAP_ENVIRONMENT`, `ENV`, or `development` | |
128
+ | `root_directory` | `os.getcwd()` | Used to mark frames as `in_app` |
129
+ | `async_` | `True` | Background-thread delivery |
130
+ | `logger` | `logging.getLogger("errorgap")` | Pass `None` to silence |
131
+ | `filter_keys` | `("password", "token", ...)` | Substring match, case-insensitive |
132
+ | `capture_globals` | `True` | Install `sys.excepthook` |
133
+
134
+ ## Graceful shutdown
135
+
136
+ ```python
137
+ errorgap.flush(timeout=5) # wait for queued deliveries
138
+ errorgap.shutdown(timeout=5) # flush + tear down background thread
139
+ ```
140
+
141
+ ## Verify
142
+
143
+ ```sh
144
+ curl -sS -X POST "$ERRORGAP_ENDPOINT/api/projects/$ERRORGAP_PROJECT_SLUG/notices" \
145
+ -H "content-type: application/json" \
146
+ -H "x-errorgap-project-key: $ERRORGAP_API_KEY" \
147
+ -d '{"errors":[{"type":"ErrorgapInstallTest","message":"Errorgap install verification"}],"context":{"environment":"development"}}'
148
+ ```
149
+
150
+ Then trigger a real error and confirm it appears in the Errorgap UI.
151
+
152
+ ## Development
153
+
154
+ ```sh
155
+ python -m venv .venv
156
+ source .venv/bin/activate
157
+ pip install -e ".[dev]"
158
+ pytest
159
+ ```
160
+
161
+ ## License
162
+
163
+ MIT.
@@ -0,0 +1,15 @@
1
+ errorgap/__init__.py,sha256=sPT-p-7L5tH9Js5JD4s3hDQPQWWbPIGt0jM8tI2VSIk,2801
2
+ errorgap/backtrace.py,sha256=6728DCVjvxOBYcp8v_zIq2D7OUnNnxOaJyzsx1X4N84,1224
3
+ errorgap/client.py,sha256=gn9KJUpGv_66DxZrEv-ABxGKh6yL0P7SUE5uSPq5_Gw,5537
4
+ errorgap/configuration.py,sha256=kEvybDZMdQ-Eq5EJ9QcMnsy9hMkT7R1qP-OsGIZpshc,1475
5
+ errorgap/django.py,sha256=xs6HLVsxzcsXQNiTpxy_NjaApGXCSQfgtMbXzYWSLzU,2514
6
+ errorgap/fastapi.py,sha256=W_sHvMLb9vNRK74sk8XyA85UWsOO9FciNLfPJAXVrJI,2017
7
+ errorgap/filter.py,sha256=kAvjCocwAJHI0u0DVSbQtBInbBGqebzz4fd2jbrHhb8,750
8
+ errorgap/flask.py,sha256=CoYEXdGZy8aGkhN-AIOtJPVKuco3624uX0m_t6BVDHQ,2650
9
+ errorgap/handlers.py,sha256=WOkKxxy2LguF1BU-VfVAecGSdLERgjRWCFj4Kz1p8M4,1033
10
+ errorgap/notice.py,sha256=uRj1aQKu9GWvOlkAw-6zuQGQV3i3xKIqbxR2LRT2VbY,1468
11
+ errorgap/version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
12
+ errorgap-0.1.0.dist-info/METADATA,sha256=nQJx_mVjp8vfE36JnjvuvC7HuEtZ3sX7KvmzIHF4cDc,4364
13
+ errorgap-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
14
+ errorgap-0.1.0.dist-info/licenses/LICENSE,sha256=_skACZib1dUUS61LlXEDHa5ZgW6SBrScsIIJjbgTC0c,1065
15
+ errorgap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Errorgap
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.