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.
Files changed (21) hide show
  1. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/.gitignore +0 -1
  2. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/CHANGELOG.md +27 -0
  3. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/PKG-INFO +2 -2
  4. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/pyproject.toml +2 -2
  5. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/src/hawkapi_sentry/__init__.py +1 -1
  6. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/src/hawkapi_sentry/_context.py +47 -2
  7. hawkapi_sentry-0.2.0/src/hawkapi_sentry/_middleware.py +117 -0
  8. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/src/hawkapi_sentry/_plugin.py +8 -6
  9. hawkapi_sentry-0.2.0/tests/test_security.py +83 -0
  10. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/uv.lock +1 -1
  11. hawkapi_sentry-0.1.0/src/hawkapi_sentry/_middleware.py +0 -61
  12. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/.github/workflows/ci.yml +0 -0
  13. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/.github/workflows/release.yml +0 -0
  14. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/LICENSE +0 -0
  15. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/README.md +0 -0
  16. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/__init__.py +0 -0
  17. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/conftest.py +0 -0
  18. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_context.py +0 -0
  19. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_integration.py +0 -0
  20. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_middleware.py +0 -0
  21. {hawkapi_sentry-0.1.0 → hawkapi_sentry-0.2.0}/tests/test_plugin.py +0 -0
@@ -31,6 +31,5 @@ coverage.xml
31
31
  .idea/
32
32
  .vscode/
33
33
  .history/
34
- .claude/
35
34
  site/
36
35
  .remember/
@@ -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.1.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 :: 4 - Beta
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.1.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 :: 4 - Beta",
18
+ "Development Status :: 5 - Production/Stable",
19
19
  "Framework :: AsyncIO",
20
20
  "Intended Audience :: Developers",
21
21
  "License :: OSI Approved :: MIT License",
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from hawkapi_sentry._middleware import SentryMiddleware
6
6
  from hawkapi_sentry._plugin import SentryPlugin
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.0"
9
9
 
10
10
  __all__ = [
11
11
  "SentryMiddleware",
@@ -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(request: Any) -> dict[str, Any]:
46
- """Build a Sentry request context dict from a HawkAPI Request."""
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 redact_headers, request_context
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
@@ -56,7 +56,7 @@ wheels = [
56
56
 
57
57
  [[package]]
58
58
  name = "hawkapi-sentry"
59
- version = "0.1.0"
59
+ version = "0.2.0"
60
60
  source = { editable = "." }
61
61
  dependencies = [
62
62
  { name = "hawkapi" },
@@ -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