hawkapi-sentry 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,14 @@
1
+ """hawkapi-sentry — Sentry integration for HawkAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from hawkapi_sentry._middleware import SentryMiddleware
6
+ from hawkapi_sentry._plugin import SentryPlugin
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "SentryMiddleware",
12
+ "SentryPlugin",
13
+ "__version__",
14
+ ]
@@ -0,0 +1,61 @@
1
+ """Request context helpers for Sentry event enrichment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ _REDACT_HEADER_NAMES = frozenset(
8
+ [
9
+ "authorization",
10
+ "cookie",
11
+ "x-api-key",
12
+ "x-auth-token",
13
+ "proxy-authorization",
14
+ ]
15
+ )
16
+
17
+ _FILTERED = "[Filtered]"
18
+
19
+
20
+ def redact_headers(headers: Any) -> dict[str, str]:
21
+ """Return a dict of headers with sensitive values masked.
22
+
23
+ Accepts any iterable of (key, value) pairs or an object with an .items()
24
+ method — covers both HawkAPI Headers (__iter__ yields tuples) and plain dicts.
25
+ """
26
+ result: dict[str, str] = {}
27
+ items: Any = headers.items() if hasattr(headers, "items") else headers
28
+ for key, value in items:
29
+ if key.lower() in _REDACT_HEADER_NAMES:
30
+ result[key] = _FILTERED
31
+ else:
32
+ result[key] = value
33
+ return result
34
+
35
+
36
+ def status_class(status_code: int) -> str:
37
+ """Map an HTTP status code to an OTel/Sentry transaction status string."""
38
+ if status_code < 400:
39
+ return "ok"
40
+ if status_code < 500:
41
+ return "invalid_argument"
42
+ return "internal_error"
43
+
44
+
45
+ def request_context(request: Any) -> dict[str, Any]:
46
+ """Build a Sentry request context dict from a HawkAPI Request."""
47
+ url: str = request.url
48
+ qs_raw: Any = request.query_string
49
+ qs: str = qs_raw.decode("latin-1") if hasattr(qs_raw, "decode") else str(qs_raw)
50
+ return {
51
+ "method": request.method,
52
+ "url": url,
53
+ "headers": redact_headers(request.headers),
54
+ "query_string": qs,
55
+ }
56
+
57
+
58
+ # Keep underscore aliases so tests that import _redact_headers etc. still work
59
+ _redact_headers = redact_headers
60
+ _status_class = status_class
61
+ _request_context = request_context
@@ -0,0 +1,61 @@
1
+ """SentryMiddleware — per-request Sentry transaction and breadcrumb."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import sentry_sdk
8
+ from hawkapi.middleware.base import Middleware
9
+ from hawkapi.requests.request import Request
10
+ from hawkapi.responses.json_response import JSONResponse
11
+ from hawkapi.responses.response import Response
12
+
13
+ from hawkapi_sentry._context import status_class
14
+
15
+
16
+ class SentryMiddleware(Middleware):
17
+ """Starts a Sentry performance transaction for every HTTP request."""
18
+
19
+ async def before_request(self, request: Request) -> Request | Response | JSONResponse | None:
20
+ """Start a Sentry transaction and add a breadcrumb."""
21
+ method: str = request.method
22
+ path: str = request.path
23
+
24
+ # Extract traceparent for distributed tracing
25
+ traceparent = request.headers.get("sentry-trace") or request.headers.get("traceparent")
26
+
27
+ transaction = sentry_sdk.start_transaction(
28
+ op="http.server",
29
+ name=f"{method} {path}",
30
+ source="url",
31
+ )
32
+ if traceparent:
33
+ transaction.continuing_trace({"sentry-trace": traceparent}) # type: ignore[attr-defined]
34
+
35
+ request.state._sentry_tx = transaction # type: ignore[attr-defined]
36
+ transaction.__enter__() # type: ignore[attr-defined]
37
+
38
+ sentry_sdk.add_breadcrumb(
39
+ category="request",
40
+ message=f"{method} {path}",
41
+ level="info",
42
+ )
43
+
44
+ return None
45
+
46
+ async def after_response(
47
+ self, request: Request, response: Response | JSONResponse
48
+ ) -> Response | JSONResponse | None:
49
+ """Finish the Sentry transaction with HTTP metadata."""
50
+ tx: Any = getattr(getattr(request, "state", None), "_sentry_tx", None)
51
+ if tx is None:
52
+ return None
53
+
54
+ status_code: int = response.status_code
55
+ tx.set_tag("http.method", request.method)
56
+ tx.set_tag("http.status_code", str(status_code))
57
+ tx.set_tag("http.target", request.path)
58
+ tx.set_status(status_class(status_code))
59
+ tx.__exit__(None, None, None) # type: ignore[attr-defined]
60
+
61
+ return None
@@ -0,0 +1,127 @@
1
+ """SentryPlugin — HawkAPI Plugin that initialises and drives the Sentry SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import sentry_sdk
10
+ from hawkapi.plugins import Plugin
11
+
12
+ from hawkapi_sentry._context import redact_headers, request_context
13
+
14
+ logger = logging.getLogger("hawkapi_sentry")
15
+
16
+ _DEFAULT_IGNORE: tuple[int, ...] = (404, 401, 403)
17
+
18
+
19
+ class SentryPlugin(Plugin):
20
+ """HawkAPI plugin that wires Sentry error and performance monitoring."""
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ dsn: str | None = None,
26
+ environment: str = "production",
27
+ release: str | None = None,
28
+ traces_sample_rate: float = 0.0,
29
+ profiles_sample_rate: float = 0.0,
30
+ include_user_data: bool = False,
31
+ user_getter: Callable[[Any], dict[str, Any]] | None = None,
32
+ before_send: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None]
33
+ | None = None,
34
+ tags: dict[str, str] | None = None,
35
+ ignore_status_codes: tuple[int, ...] = _DEFAULT_IGNORE,
36
+ transport: Any = None,
37
+ ) -> None:
38
+ self._dsn = dsn
39
+ self._environment = environment
40
+ self._release = release
41
+ self._traces_sample_rate = traces_sample_rate
42
+ self._profiles_sample_rate = profiles_sample_rate
43
+ self._include_user_data = include_user_data
44
+ self._user_getter = user_getter
45
+ self._before_send = before_send
46
+ self._tags: dict[str, str] = tags or {}
47
+ self._ignore_status_codes = ignore_status_codes
48
+ self._transport = transport
49
+ self._initialised = False
50
+
51
+ # ------------------------------------------------------------------
52
+ # Plugin hooks
53
+ # ------------------------------------------------------------------
54
+
55
+ def on_startup(self) -> None:
56
+ """Initialise the Sentry SDK. No-op when dsn is empty or None."""
57
+ if not self._dsn:
58
+ logger.info("hawkapi_sentry: no DSN provided — Sentry disabled (no-op mode)")
59
+ return
60
+
61
+ init_kwargs: dict[str, Any] = {
62
+ "dsn": self._dsn,
63
+ "environment": self._environment,
64
+ "traces_sample_rate": self._traces_sample_rate,
65
+ "profiles_sample_rate": self._profiles_sample_rate,
66
+ }
67
+ if self._release is not None:
68
+ init_kwargs["release"] = self._release
69
+ if self._before_send is not None:
70
+ init_kwargs["before_send"] = self._before_send
71
+ if self._transport is not None:
72
+ init_kwargs["transport"] = self._transport
73
+
74
+ sentry_sdk.init(**init_kwargs)
75
+ self._initialised = True
76
+
77
+ logger.info(
78
+ "hawkapi_sentry: Sentry initialised (environment=%s, traces_sample_rate=%s)",
79
+ self._environment,
80
+ self._traces_sample_rate,
81
+ )
82
+
83
+ def on_shutdown(self) -> None:
84
+ """Flush pending Sentry events before the process exits."""
85
+ sentry_sdk.flush(timeout=2.0)
86
+
87
+ def on_exception(self, request: Any, exc: Exception) -> None:
88
+ """Capture an unhandled exception in Sentry, enriched with request context."""
89
+ status_code = getattr(exc, "status_code", 500)
90
+ if status_code in self._ignore_status_codes:
91
+ return
92
+
93
+ with sentry_sdk.new_scope() as scope:
94
+ scope.set_context("request", request_context(request))
95
+
96
+ user_data = self._resolve_user(request)
97
+ if user_data:
98
+ scope.set_user(user_data)
99
+
100
+ for tag_key, tag_val in self._tags.items():
101
+ scope.set_tag(tag_key, tag_val)
102
+
103
+ sentry_sdk.capture_exception(exc)
104
+
105
+ # ------------------------------------------------------------------
106
+ # Internal helpers
107
+ # ------------------------------------------------------------------
108
+
109
+ def _resolve_user(self, request: Any) -> dict[str, Any] | None:
110
+ """Return user dict from user_getter or request.state.user, or None."""
111
+ if self._user_getter is not None:
112
+ try:
113
+ return self._user_getter(request)
114
+ except Exception:
115
+ logger.debug("hawkapi_sentry: user_getter raised — skipping user context")
116
+ return None
117
+ if self._include_user_data:
118
+ state = getattr(request, "state", None)
119
+ if state is not None:
120
+ user = getattr(state, "user", None)
121
+ if isinstance(user, dict):
122
+ return user # type: ignore[return-value]
123
+ return None
124
+
125
+ def _redact_headers_helper(self, headers: Any) -> dict[str, str]:
126
+ """Delegate to module-level helper."""
127
+ return redact_headers(headers)
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: hawkapi-sentry
3
+ Version: 0.1.0
4
+ Summary: Sentry integration for HawkAPI — plugin + middleware
5
+ Project-URL: Homepage, https://github.com/ashimov/hawkapi-sentry
6
+ Project-URL: Repository, https://github.com/ashimov/hawkapi-sentry
7
+ Project-URL: Issues, https://github.com/ashimov/hawkapi-sentry/issues
8
+ Author-email: HawkAPI Contributors <hawkapi@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: error-tracking,hawkapi,monitoring,observability,sentry
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: hawkapi>=0.1.3
23
+ Requires-Dist: sentry-sdk>=2.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: httpx>=0.27; extra == 'dev'
26
+ Requires-Dist: pyright>=1.1; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.8; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # hawkapi-sentry
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/hawkapi-sentry)](https://pypi.org/project/hawkapi-sentry/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/hawkapi-sentry)](https://pypi.org/project/hawkapi-sentry/)
36
+ [![License](https://img.shields.io/pypi/l/hawkapi-sentry)](LICENSE)
37
+ [![CI](https://github.com/ashimov/hawkapi-sentry/actions/workflows/ci.yml/badge.svg)](https://github.com/ashimov/hawkapi-sentry/actions/workflows/ci.yml)
38
+ [![Downloads](https://img.shields.io/pypi/dm/hawkapi-sentry)](https://pypi.org/project/hawkapi-sentry/)
39
+
40
+ Sentry integration for [HawkAPI](https://github.com/ashimov/HawkAPI) — a plugin that initialises the Sentry SDK on startup and captures unhandled exceptions, plus a middleware that creates a performance transaction per request.
41
+
42
+ ---
43
+
44
+ ## Quickstart
45
+
46
+ ```bash
47
+ pip install hawkapi-sentry
48
+ ```
49
+
50
+ ```python
51
+ from hawkapi import HawkAPI
52
+ from hawkapi_sentry import SentryPlugin, SentryMiddleware
53
+
54
+ app = HawkAPI()
55
+
56
+ app.add_plugin(
57
+ SentryPlugin(
58
+ dsn="https://key@sentry.io/123",
59
+ environment="production",
60
+ traces_sample_rate=0.1,
61
+ )
62
+ )
63
+ app.add_middleware(SentryMiddleware)
64
+ ```
65
+
66
+ That's it. Every unhandled exception is captured with full request context; every request gets a Sentry performance transaction.
67
+
68
+ ---
69
+
70
+ ## `SentryPlugin` parameter reference
71
+
72
+ | Parameter | Type | Default | Description |
73
+ |---|---|---|---|
74
+ | `dsn` | `str \| None` | `None` | Sentry DSN. Empty/`None` = no-op mode (safe for local dev). |
75
+ | `environment` | `str` | `"production"` | Sentry environment tag. |
76
+ | `release` | `str \| None` | `None` | Release string. `None` = not set. |
77
+ | `traces_sample_rate` | `float` | `0.0` | Fraction of transactions to sample for performance monitoring. |
78
+ | `profiles_sample_rate` | `float` | `0.0` | Fraction of sampled transactions to profile. |
79
+ | `include_user_data` | `bool` | `False` | When `True`, attach `request.state.user` dict to Sentry events. |
80
+ | `user_getter` | `Callable[[Request], dict] \| None` | `None` | Custom callable to extract user data from the request. Takes priority over `include_user_data`. |
81
+ | `before_send` | `Callable \| None` | `None` | Passed directly to `sentry_sdk.init`. Return `None` to drop an event. |
82
+ | `tags` | `dict[str, str] \| None` | `None` | Global tags applied to every captured event. |
83
+ | `ignore_status_codes` | `tuple[int, ...]` | `(404, 401, 403)` | HTTP status codes for which exceptions are **not** sent to Sentry. |
84
+
85
+ ---
86
+
87
+ ## Migration from `sentry-sdk[fastapi]`
88
+
89
+ | FastAPI / starlette-sentry | hawkapi-sentry |
90
+ |---|---|
91
+ | `sentry_sdk.init(integrations=[StarletteIntegration(), FastApiIntegration()])` | `app.add_plugin(SentryPlugin(dsn=...))` |
92
+ | `SentryAsgiMiddleware(app)` | `app.add_middleware(SentryMiddleware)` |
93
+ | `before_send` kwarg to `sentry_sdk.init` | `SentryPlugin(before_send=...)` |
94
+ | Manual `with sentry_sdk.push_scope() as scope: scope.set_user(...)` | `SentryPlugin(user_getter=lambda req: {...})` |
95
+
96
+ The main difference: HawkAPI plugins own the SDK lifecycle, so you never call `sentry_sdk.init()` yourself — `SentryPlugin.on_startup()` does it.
97
+
98
+ ---
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ # Clone and install in editable mode with dev extras
104
+ git clone https://github.com/ashimov/hawkapi-sentry.git
105
+ cd hawkapi-sentry
106
+ uv sync --extra dev
107
+
108
+ # Run tests
109
+ uv run pytest tests/ -q
110
+
111
+ # Lint
112
+ uv run ruff check .
113
+ uv run ruff format .
114
+
115
+ # Type-check
116
+ uv run pyright src/
117
+ ```
118
+
119
+ ---
120
+
121
+ ## License
122
+
123
+ [MIT](LICENSE) — Copyright (c) 2026 HawkAPI Contributors.
@@ -0,0 +1,8 @@
1
+ hawkapi_sentry/__init__.py,sha256=60d3Nn5rfxisa4bUfuSfaWaw8jTGWAltMiKi37IoYHM,299
2
+ hawkapi_sentry/_context.py,sha256=fqp4Vqc0HBrIbaSeNpIpASaYZFAY_r5s8-dpiMs-XmM,1749
3
+ hawkapi_sentry/_middleware.py,sha256=p-EESG5_ZfE5e8ihMyGNHmdAdorxkkOJGLo23s-AO64,2152
4
+ hawkapi_sentry/_plugin.py,sha256=UUKcAb45_Wv0Gbueiu1XY2zfTM_qzQaPILYz9bTl_BA,4703
5
+ hawkapi_sentry-0.1.0.dist-info/METADATA,sha256=H_ystdiw6gQst9H08ZZkNCnoYevGLSgm9hdE-z-sBEc,4742
6
+ hawkapi_sentry-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ hawkapi_sentry-0.1.0.dist-info/licenses/LICENSE,sha256=_RpjhvsfLqqeG_gv2cRatjIxCTGXTpXhKU9jqLZXYa4,1077
8
+ hawkapi_sentry-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HawkAPI Contributors
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.