hawkapi-sentry 0.1.0__tar.gz → 0.2.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.
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/.gitignore +0 -1
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/CHANGELOG.md +27 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/PKG-INFO +2 -2
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/pyproject.toml +2 -2
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/src/hawkapi_sentry/__init__.py +1 -1
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/src/hawkapi_sentry/_context.py +47 -2
- hawkapi_sentry-0.2.0/src/hawkapi_sentry/_middleware.py +117 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/src/hawkapi_sentry/_plugin.py +8 -6
- hawkapi_sentry-0.2.0/tests/test_security.py +83 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/uv.lock +1 -1
- hawkapi_sentry-0.1.0/src/hawkapi_sentry/_middleware.py +0 -61
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/.github/workflows/ci.yml +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/.github/workflows/release.yml +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/LICENSE +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/README.md +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/__init__.py +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/conftest.py +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_context.py +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_integration.py +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_middleware.py +0 -0
- {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_plugin.py +0 -0
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-05-16
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- Fix distributed-trace continuation to use the modern
|
|
15
|
+
`sentry_sdk.continue_trace` API instead of the broken
|
|
16
|
+
`transaction.continuing_trace` call.
|
|
17
|
+
- Mask sensitive query parameters (`token`, `key`, `api_key`, `password`,
|
|
18
|
+
`secret`, `access_token`, `refresh_token`) in the Sentry request context
|
|
19
|
+
(CWE-200). `request_context` now takes an optional
|
|
20
|
+
`sensitive_query_params` argument.
|
|
21
|
+
- `send_default_pii` is explicitly defaulted to `False` in
|
|
22
|
+
`sentry_sdk.init`.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- After routing matches, the transaction is renamed to the route template
|
|
27
|
+
(e.g. `GET /users/{id}`) with `source="route"` so transactions group
|
|
28
|
+
correctly in Sentry.
|
|
29
|
+
- Removed the unused private `_redact_headers_helper` method.
|
|
30
|
+
|
|
31
|
+
## [0.1.1] - 2026-04-19
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Bump `Development Status` classifier to `5 - Production/Stable`.
|
|
36
|
+
|
|
10
37
|
## [0.1.0] - 2026-04-19
|
|
11
38
|
|
|
12
39
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hawkapi-sentry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Sentry integration for HawkAPI — plugin + middleware
|
|
5
5
|
Project-URL: Homepage, https://github.com/ashimov/hawkapi-sentry
|
|
6
6
|
Project-URL: Repository, https://github.com/ashimov/hawkapi-sentry
|
|
@@ -9,7 +9,7 @@ Author-email: HawkAPI Contributors <hawkapi@users.noreply.github.com>
|
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Keywords: error-tracking,hawkapi,monitoring,observability,sentry
|
|
12
|
-
Classifier: Development Status ::
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
13
|
Classifier: Framework :: AsyncIO
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hawkapi-sentry"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Sentry integration for HawkAPI — plugin + middleware"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -15,7 +15,7 @@ authors = [
|
|
|
15
15
|
]
|
|
16
16
|
keywords = ["hawkapi", "sentry", "observability", "error-tracking", "monitoring"]
|
|
17
17
|
classifiers = [
|
|
18
|
-
"Development Status ::
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
19
|
"Framework :: AsyncIO",
|
|
20
20
|
"Intended Audience :: Developers",
|
|
21
21
|
"License :: OSI Approved :: MIT License",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
|
+
from urllib.parse import parse_qsl, quote, urlencode
|
|
6
7
|
|
|
7
8
|
_REDACT_HEADER_NAMES = frozenset(
|
|
8
9
|
[
|
|
@@ -14,7 +15,20 @@ _REDACT_HEADER_NAMES = frozenset(
|
|
|
14
15
|
]
|
|
15
16
|
)
|
|
16
17
|
|
|
18
|
+
_SENSITIVE_QUERY_PARAMS: frozenset[str] = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"token",
|
|
21
|
+
"key",
|
|
22
|
+
"api_key",
|
|
23
|
+
"password",
|
|
24
|
+
"secret",
|
|
25
|
+
"access_token",
|
|
26
|
+
"refresh_token",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
17
30
|
_FILTERED = "[Filtered]"
|
|
31
|
+
_REDACTED_VALUE = "***"
|
|
18
32
|
|
|
19
33
|
|
|
20
34
|
def redact_headers(headers: Any) -> dict[str, str]:
|
|
@@ -33,6 +47,28 @@ def redact_headers(headers: Any) -> dict[str, str]:
|
|
|
33
47
|
return result
|
|
34
48
|
|
|
35
49
|
|
|
50
|
+
def redact_query_string(qs: str, sensitive: frozenset[str] | None = None) -> str:
|
|
51
|
+
"""Return *qs* with values for sensitive keys replaced by ``***``.
|
|
52
|
+
|
|
53
|
+
The ``***`` sentinel is emitted verbatim — `urlencode` would otherwise
|
|
54
|
+
percent-encode the asterisks and obscure the intent of the redaction
|
|
55
|
+
in Sentry's UI.
|
|
56
|
+
"""
|
|
57
|
+
if not qs:
|
|
58
|
+
return qs
|
|
59
|
+
targets = sensitive if sensitive is not None else _SENSITIVE_QUERY_PARAMS
|
|
60
|
+
targets = frozenset(t.lower() for t in targets)
|
|
61
|
+
pairs = parse_qsl(qs, keep_blank_values=True)
|
|
62
|
+
parts: list[str] = []
|
|
63
|
+
for k, v in pairs:
|
|
64
|
+
key = urlencode([(k, "")]).rstrip("=")
|
|
65
|
+
if k.lower() in targets:
|
|
66
|
+
parts.append(f"{key}={_REDACTED_VALUE}")
|
|
67
|
+
else:
|
|
68
|
+
parts.append(f"{key}={quote(v, safe='')}")
|
|
69
|
+
return "&".join(parts)
|
|
70
|
+
|
|
71
|
+
|
|
36
72
|
def status_class(status_code: int) -> str:
|
|
37
73
|
"""Map an HTTP status code to an OTel/Sentry transaction status string."""
|
|
38
74
|
if status_code < 400:
|
|
@@ -42,11 +78,20 @@ def status_class(status_code: int) -> str:
|
|
|
42
78
|
return "internal_error"
|
|
43
79
|
|
|
44
80
|
|
|
45
|
-
def request_context(
|
|
46
|
-
|
|
81
|
+
def request_context(
|
|
82
|
+
request: Any,
|
|
83
|
+
*,
|
|
84
|
+
sensitive_query_params: frozenset[str] | None = None,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""Build a Sentry request context dict from a HawkAPI Request.
|
|
87
|
+
|
|
88
|
+
Sensitive values in the ``query_string`` field are masked with ``***`` so
|
|
89
|
+
they never reach Sentry's UI (CWE-200).
|
|
90
|
+
"""
|
|
47
91
|
url: str = request.url
|
|
48
92
|
qs_raw: Any = request.query_string
|
|
49
93
|
qs: str = qs_raw.decode("latin-1") if hasattr(qs_raw, "decode") else str(qs_raw)
|
|
94
|
+
qs = redact_query_string(qs, sensitive_query_params)
|
|
50
95
|
return {
|
|
51
96
|
"method": request.method,
|
|
52
97
|
"url": url,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""SentryMiddleware — per-request Sentry transaction and breadcrumb."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import sentry_sdk
|
|
9
|
+
from hawkapi.middleware.base import Middleware
|
|
10
|
+
from hawkapi.requests.request import Request
|
|
11
|
+
from hawkapi.responses.json_response import JSONResponse
|
|
12
|
+
from hawkapi.responses.response import Response
|
|
13
|
+
|
|
14
|
+
from hawkapi_sentry._context import status_class
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _start_transaction(method: str, path: str, traceparent: str | None) -> Any:
|
|
18
|
+
"""Return a started Sentry transaction.
|
|
19
|
+
|
|
20
|
+
If ``traceparent`` is provided we continue the upstream trace; otherwise
|
|
21
|
+
we start a new root transaction. ``sentry_sdk.continue_trace`` is the
|
|
22
|
+
modern v2 SDK API; older callers may not have it, in which case we fall
|
|
23
|
+
back to ``Transaction.continue_from_headers``.
|
|
24
|
+
"""
|
|
25
|
+
if traceparent:
|
|
26
|
+
if hasattr(sentry_sdk, "continue_trace"):
|
|
27
|
+
return sentry_sdk.continue_trace(
|
|
28
|
+
environ_or_headers={"sentry-trace": traceparent},
|
|
29
|
+
op="http.server",
|
|
30
|
+
name=f"{method} {path}",
|
|
31
|
+
)
|
|
32
|
+
# Pre-2.x SDK fallback.
|
|
33
|
+
from sentry_sdk.tracing import Transaction # noqa: PLC0415
|
|
34
|
+
|
|
35
|
+
return Transaction.continue_from_headers(
|
|
36
|
+
{"sentry-trace": traceparent},
|
|
37
|
+
op="http.server",
|
|
38
|
+
name=f"{method} {path}",
|
|
39
|
+
)
|
|
40
|
+
return sentry_sdk.start_transaction(
|
|
41
|
+
op="http.server",
|
|
42
|
+
name=f"{method} {path}",
|
|
43
|
+
source="url",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SentryMiddleware(Middleware):
|
|
48
|
+
"""Starts a Sentry performance transaction for every HTTP request."""
|
|
49
|
+
|
|
50
|
+
async def before_request(self, request: Request) -> Request | Response | JSONResponse | None:
|
|
51
|
+
"""Start a Sentry transaction and add a breadcrumb."""
|
|
52
|
+
method: str = request.method
|
|
53
|
+
path: str = request.path
|
|
54
|
+
|
|
55
|
+
traceparent = request.headers.get("sentry-trace") or request.headers.get("traceparent")
|
|
56
|
+
transaction = _start_transaction(method, path, traceparent)
|
|
57
|
+
if (
|
|
58
|
+
traceparent
|
|
59
|
+
and hasattr(sentry_sdk, "start_transaction")
|
|
60
|
+
and not hasattr(transaction, "__enter__")
|
|
61
|
+
):
|
|
62
|
+
# ``continue_trace`` returns a Transaction object that still needs
|
|
63
|
+
# to be started via ``start_transaction`` in newer SDKs.
|
|
64
|
+
transaction = sentry_sdk.start_transaction(transaction)
|
|
65
|
+
|
|
66
|
+
request.state._sentry_tx = transaction # type: ignore[attr-defined]
|
|
67
|
+
transaction.__enter__() # type: ignore[attr-defined]
|
|
68
|
+
|
|
69
|
+
sentry_sdk.add_breadcrumb(
|
|
70
|
+
category="request",
|
|
71
|
+
message=f"{method} {path}",
|
|
72
|
+
level="info",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
async def after_response(
|
|
78
|
+
self, request: Request, response: Response | JSONResponse
|
|
79
|
+
) -> Response | JSONResponse | None:
|
|
80
|
+
"""Finish the Sentry transaction with HTTP metadata."""
|
|
81
|
+
tx: Any = getattr(getattr(request, "state", None), "_sentry_tx", None)
|
|
82
|
+
if tx is None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
status_code: int = response.status_code
|
|
86
|
+
# Once routing has run we know the matched template (e.g.
|
|
87
|
+
# ``/users/{id}``), which groups individual requests in Sentry's
|
|
88
|
+
# transaction list.
|
|
89
|
+
route_template = self._extract_route_template(request)
|
|
90
|
+
if route_template:
|
|
91
|
+
tx.name = f"{request.method} {route_template}"
|
|
92
|
+
with contextlib.suppress(Exception):
|
|
93
|
+
tx.source = "route" # type: ignore[attr-defined]
|
|
94
|
+
tx.set_tag("http.method", request.method)
|
|
95
|
+
tx.set_tag("http.status_code", str(status_code))
|
|
96
|
+
tx.set_tag("http.target", request.path)
|
|
97
|
+
tx.set_status(status_class(status_code))
|
|
98
|
+
tx.__exit__(None, None, None) # type: ignore[attr-defined]
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _extract_route_template(request: Request) -> str | None:
|
|
104
|
+
# HawkAPI exposes the matched route on ``request.scope`` (``route``)
|
|
105
|
+
# and/or ``request.scope["path_template"]`` depending on version.
|
|
106
|
+
scope_obj: Any = getattr(request, "scope", None) or {}
|
|
107
|
+
if not isinstance(scope_obj, dict):
|
|
108
|
+
return None
|
|
109
|
+
scope: dict[str, Any] = dict(scope_obj) # type: ignore[arg-type]
|
|
110
|
+
for key in ("route", "path_template", "endpoint_path"):
|
|
111
|
+
value: Any = scope.get(key)
|
|
112
|
+
if isinstance(value, str) and value:
|
|
113
|
+
return value
|
|
114
|
+
template: Any = getattr(value, "path", None) if value is not None else None
|
|
115
|
+
if isinstance(template, str) and template:
|
|
116
|
+
return template
|
|
117
|
+
return None
|
|
@@ -9,7 +9,7 @@ from typing import Any
|
|
|
9
9
|
import sentry_sdk
|
|
10
10
|
from hawkapi.plugins import Plugin
|
|
11
11
|
|
|
12
|
-
from hawkapi_sentry._context import
|
|
12
|
+
from hawkapi_sentry._context import request_context
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger("hawkapi_sentry")
|
|
15
15
|
|
|
@@ -53,7 +53,12 @@ class SentryPlugin(Plugin):
|
|
|
53
53
|
# ------------------------------------------------------------------
|
|
54
54
|
|
|
55
55
|
def on_startup(self) -> None:
|
|
56
|
-
"""Initialise the Sentry SDK. No-op when dsn is empty or None.
|
|
56
|
+
"""Initialise the Sentry SDK. No-op when dsn is empty or None.
|
|
57
|
+
|
|
58
|
+
``send_default_pii`` is forced off by default so the SDK does not
|
|
59
|
+
attach IP addresses, cookies, or other PII to every event. Pass
|
|
60
|
+
``before_send`` to opt back in selectively.
|
|
61
|
+
"""
|
|
57
62
|
if not self._dsn:
|
|
58
63
|
logger.info("hawkapi_sentry: no DSN provided — Sentry disabled (no-op mode)")
|
|
59
64
|
return
|
|
@@ -64,6 +69,7 @@ class SentryPlugin(Plugin):
|
|
|
64
69
|
"traces_sample_rate": self._traces_sample_rate,
|
|
65
70
|
"profiles_sample_rate": self._profiles_sample_rate,
|
|
66
71
|
}
|
|
72
|
+
init_kwargs.setdefault("send_default_pii", False)
|
|
67
73
|
if self._release is not None:
|
|
68
74
|
init_kwargs["release"] = self._release
|
|
69
75
|
if self._before_send is not None:
|
|
@@ -121,7 +127,3 @@ class SentryPlugin(Plugin):
|
|
|
121
127
|
if isinstance(user, dict):
|
|
122
128
|
return user # type: ignore[return-value]
|
|
123
129
|
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,83 @@
|
|
|
1
|
+
"""Regression tests for 0.2.0 hardening fixes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from hawkapi_sentry._context import redact_query_string, request_context
|
|
10
|
+
from hawkapi_sentry._middleware import SentryMiddleware
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeHeaders:
|
|
14
|
+
def __init__(self, data: dict[str, str] | None = None) -> None:
|
|
15
|
+
self._data = data or {}
|
|
16
|
+
|
|
17
|
+
def get(self, key: str, default: str | None = None) -> str | None:
|
|
18
|
+
return self._data.get(key, default)
|
|
19
|
+
|
|
20
|
+
def items(self) -> list[tuple[str, str]]:
|
|
21
|
+
return list(self._data.items())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FakeState:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _FakeRequest:
|
|
29
|
+
def __init__(self, headers: dict[str, str] | None = None) -> None:
|
|
30
|
+
self.method = "POST"
|
|
31
|
+
self.path = "/api/items"
|
|
32
|
+
self.headers = _FakeHeaders(headers or {})
|
|
33
|
+
self.state = _FakeState()
|
|
34
|
+
self.scope = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_traceparent_continues_trace() -> None:
|
|
39
|
+
"""A request carrying ``sentry-trace`` must reach ``continue_trace``."""
|
|
40
|
+
mock_tx = MagicMock()
|
|
41
|
+
mock_tx.__enter__ = MagicMock(return_value=mock_tx)
|
|
42
|
+
mock_tx.__exit__ = MagicMock(return_value=False)
|
|
43
|
+
|
|
44
|
+
mw = SentryMiddleware(app=MagicMock()) # type: ignore[arg-type]
|
|
45
|
+
parent = "0123456789abcdef0123456789abcdef-aabbccddeeff0011-1"
|
|
46
|
+
req = _FakeRequest(headers={"sentry-trace": parent})
|
|
47
|
+
|
|
48
|
+
with (
|
|
49
|
+
patch("hawkapi_sentry._middleware.sentry_sdk.continue_trace") as m_cont,
|
|
50
|
+
patch(
|
|
51
|
+
"hawkapi_sentry._middleware.sentry_sdk.start_transaction",
|
|
52
|
+
return_value=mock_tx,
|
|
53
|
+
),
|
|
54
|
+
patch("sentry_sdk.add_breadcrumb"),
|
|
55
|
+
):
|
|
56
|
+
m_cont.return_value = mock_tx
|
|
57
|
+
await mw.before_request(req) # type: ignore[arg-type]
|
|
58
|
+
m_cont.assert_called_once()
|
|
59
|
+
called_kwargs = m_cont.call_args.kwargs
|
|
60
|
+
assert called_kwargs["environ_or_headers"] == {"sentry-trace": parent}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_query_param_redaction_in_request_context() -> None:
|
|
64
|
+
"""Sensitive query params must not reach Sentry's request context."""
|
|
65
|
+
|
|
66
|
+
class R:
|
|
67
|
+
method = "GET"
|
|
68
|
+
url = "https://example.com/x?token=abc&page=2"
|
|
69
|
+
query_string = b"token=abc&page=2&api_key=zzz"
|
|
70
|
+
headers = _FakeHeaders({})
|
|
71
|
+
|
|
72
|
+
ctx = request_context(R())
|
|
73
|
+
assert "token=***" in ctx["query_string"]
|
|
74
|
+
assert "api_key=***" in ctx["query_string"]
|
|
75
|
+
assert "abc" not in ctx["query_string"]
|
|
76
|
+
assert "page=2" in ctx["query_string"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_redact_query_string_custom_set() -> None:
|
|
80
|
+
"""Callers can extend the redaction set."""
|
|
81
|
+
out = redact_query_string("token=a&shared=b", frozenset({"shared"}))
|
|
82
|
+
assert "shared=***" in out
|
|
83
|
+
assert "token=a" in out
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|