Skip to content

Commit aff567c

Browse files
olivermeyerclaude
andcommitted
fix(sentry): send trace headers to allowed hosts
Add SentrySettings.trace_propagation_targets with the default [] and pass it to sentry_sdk.init. The SDK default [".*"] adds sentry-trace and baggage to every outbound request inside a transaction, also to partner systems. The baggage header carries the release, the environment and the public key of the DSN. BREAKING CHANGE: Sentry no longer adds sentry-trace and baggage headers to outbound requests. Set {PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS to a JSON list of host regexes to opt in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 619f6a1 commit aff567c

4 files changed

Lines changed: 108 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ set.
162162
| `{PREFIX}SENTRY_SEND_DEFAULT_PII` | `false` | Include personally-identifiable information in events. |
163163
| `{PREFIX}SENTRY_INCLUDE_LOCAL_VARIABLES` | `false` | Include the local variables of each stack frame in error events. Locals can contain credentials and personal data. |
164164
| `{PREFIX}SENTRY_MAX_REQUEST_BODY_SIZE` | `"never"` | Maximum size of HTTP request bodies in error events: `"never"`, `"small"` (up to 1 KB), `"medium"` (up to 10 KB) or `"always"`. Request bodies can contain credentials and personal data. The FastAPI integration sends JSON bodies even when `SEND_DEFAULT_PII` is `false`. |
165+
| `{PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS` | `[]` | JSON list of regexes. Sentry adds the `sentry-trace` and `baggage` headers only to outbound requests whose URL matches one of them. The `baggage` header contains the release, the environment and the public key of the DSN. With the default, no outbound request gets these headers. Example: `{PREFIX}SENTRY_TRACE_PROPAGATION_TARGETS='["internal\\.example\\.com"]'`. |
165166
| `{PREFIX}SENTRY_MAX_BREADCRUMBS` | `50` | Maximum breadcrumbs stored per event. |
166167
| `{PREFIX}SENTRY_SAMPLE_RATE` | `1.0` | Error event sample rate (0.0–1.0). |
167168
| `{PREFIX}SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Transaction/trace sample rate. |

‎src/aignostics_foundry_core/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
182182

183183
- **Purpose**: Bootstraps Sentry SDK with all project-specific metadata supplied as explicit parameters, making the initialisation reusable across any project without hard-coded constants.
184184
- **Key Features**:
185-
- `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs`
185+
- `SentrySettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}SENTRY_`). Fields: `enabled`, `dsn` (validated HTTPS Sentry URL), `debug`, `send_default_pii`, `include_local_variables` (default `False`; frame locals are not sent), `max_request_body_size` (`"never"` | `"small"` | `"medium"` | `"always"`, default `"never"`; request bodies are not sent), `trace_propagation_targets` (`list[str]` of URL regexes, default `[]`; no outbound request gets `sentry-trace` or `baggage` headers; the env var takes a JSON list), `max_breadcrumbs`, `sample_rate`, `traces_sample_rate`, `profiles_sample_rate`, `profile_session_sample_rate`, `profile_lifecycle`, `enable_logs`
186186
- `sentry_initialize(integrations, *, context=None)` — derives all project-specific values (name, version, environment, URLs, runtime flags) from *context* (or the global context); env prefix and env file are read from `ctx.env_prefix` and `ctx.env_file`; initialises Sentry SDK when enabled and DSN present; sets `aignx/base` context; removes the query string and the fragment (`http.query`, `http.fragment` and the query of `url`) from HTTP breadcrumbs and from the span data of transactions, through `before_breadcrumb` and `before_send_transaction` hooks; suppresses noisy loggers; returns `True` on success, `False` otherwise
187187
- `set_sentry_user(user, role_claim)` — maps `sub` → `id`, `org_id` and the optional role claim into Sentry scope; no other claims (no email, name or other personal data); pass `None` to clear context; no-op when `sentry_sdk` is absent
188188
- **Location**: `aignostics_foundry_core/sentry.py`

‎src/aignostics_foundry_core/sentry.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,20 @@ def __init__(self, **kwargs: Any) -> None: # ruff: ignore[any-type]
248248
),
249249
]
250250

251+
trace_propagation_targets: Annotated[
252+
list[str],
253+
Field(
254+
description=(
255+
"Regexes of the outbound request URLs that get the sentry-trace and baggage headers. "
256+
"Empty by default, so no outbound request gets them: the baggage header carries the "
257+
"release, the environment and the public key of the DSN. The env var takes a JSON list "
258+
"(https://docs.sentry.io/platforms/python/configuration/options/#trace-propagation-targets)"
259+
),
260+
examples=[[r"internal\.example\.com"]],
261+
default_factory=list,
262+
),
263+
]
264+
251265
max_breadcrumbs: Annotated[
252266
int,
253267
Field(
@@ -355,6 +369,7 @@ def sentry_initialize(
355369
send_default_pii=settings.send_default_pii,
356370
include_local_variables=settings.include_local_variables,
357371
max_request_body_size=settings.max_request_body_size,
372+
trace_propagation_targets=settings.trace_propagation_targets,
358373
sample_rate=settings.sample_rate,
359374
traces_sample_rate=settings.traces_sample_rate,
360375
profiles_sample_rate=settings.profiles_sample_rate,

‎tests/aignostics_foundry_core/sentry_test.py‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Tests for aignostics_foundry_core.sentry."""
22

33
import json
4-
from collections.abc import Generator
4+
from collections.abc import Callable, Generator
55
from typing import TYPE_CHECKING, Any
66
from unittest.mock import patch
77

88
import httpx
9+
import httpx2
910
import pytest
1011
import sentry_sdk
1112
from fastapi import FastAPI
@@ -52,6 +53,12 @@
5253
_HTTP_QUERY_KEY = "http.query"
5354
_HTTP_FRAGMENT_KEY = "http.fragment"
5455
_HTTPLIB_CATEGORY = "httplib"
56+
# The env var form that README.md documents: a JSON list with one regex, internal\.example\.com
57+
_INTERNAL_TRACE_TARGETS_JSON = r'["internal\\.example\\.com"]'
58+
_INTERNAL_URL = "https://internal.example.com/"
59+
_PARTNER_URL = "https://partner.example.org/"
60+
_SENTRY_TRACE_HEADER = "sentry-trace"
61+
_BAGGAGE_HEADER = "baggage"
5562

5663

5764
class _CapturingTransport(Transport):
@@ -155,6 +162,54 @@ def _get_signed_blob_url() -> None:
155162
client.get(_SIGNED_BLOB_URL)
156163

157164

165+
def _httpx_request_headers(url: str) -> dict[str, str]:
166+
"""Send a GET to *url* through an :class:`httpx.Client` on a mock transport, inside a Sentry transaction.
167+
168+
The Sentry httpx integration adds trace headers only to requests inside a transaction, as in a
169+
request handler of a service.
170+
171+
Returns:
172+
dict[str, str]: The headers of the request that reached the transport.
173+
"""
174+
sent: list[httpx.Request] = []
175+
176+
def handle(request: httpx.Request) -> httpx.Response:
177+
sent.append(request)
178+
return httpx.Response(200)
179+
180+
with (
181+
sentry_sdk.start_transaction(name=_PROBE_MESSAGE),
182+
httpx.Client(transport=httpx.MockTransport(handle)) as client,
183+
):
184+
client.get(url)
185+
(request,) = sent
186+
return dict(request.headers)
187+
188+
189+
def _httpx2_request_headers(url: str) -> dict[str, str]:
190+
"""Send a GET to *url* through an :class:`httpx2.Client` on a mock transport, inside a Sentry transaction.
191+
192+
The Sentry httpx2 integration adds trace headers only to requests inside a transaction, as in a
193+
request handler of a service.
194+
195+
Returns:
196+
dict[str, str]: The headers of the request that reached the transport.
197+
"""
198+
sent: list[httpx2.Request] = []
199+
200+
def handle(request: httpx2.Request) -> httpx2.Response:
201+
sent.append(request)
202+
return httpx2.Response(200)
203+
204+
with (
205+
sentry_sdk.start_transaction(name=_PROBE_MESSAGE),
206+
httpx2.Client(transport=httpx2.MockTransport(handle)) as client,
207+
):
208+
client.get(url)
209+
(request,) = sent
210+
return dict(request.headers)
211+
212+
158213
def _breadcrumbs(event: dict[str, Any], category: str) -> list[dict[str, Any]]:
159214
"""Return the breadcrumbs of *event* that have *category*."""
160215
return [crumb for crumb in event["breadcrumbs"]["values"] if crumb.get("category") == category]
@@ -355,6 +410,41 @@ def test_transaction_span_has_no_query_string(
355410
assert all(_HTTP_QUERY_KEY not in span["data"] for span in http_spans)
356411
assert _SIGNED_URL_MARKER not in json.dumps(transaction)
357412

413+
@pytest.mark.parametrize("send_get", [_httpx_request_headers, _httpx2_request_headers], ids=["httpx", "httpx2"])
414+
def test_outbound_request_has_no_trace_headers_by_default(
415+
self, sentry_capture: SentryCapture, send_get: Callable[[str], dict[str, str]]
416+
) -> None:
417+
"""An outbound request gets no ``sentry-trace`` or ``baggage`` header at default settings."""
418+
sentry_capture.start()
419+
420+
headers = send_get(_PARTNER_URL)
421+
422+
assert _SENTRY_TRACE_HEADER not in headers
423+
assert _BAGGAGE_HEADER not in headers
424+
425+
def test_outbound_request_to_allowed_host_has_trace_headers(
426+
self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch
427+
) -> None:
428+
"""A request to a host in TRACE_PROPAGATION_TARGETS gets the ``sentry-trace`` header."""
429+
monkeypatch.setenv(f"{_SENTRY_PREFIX}TRACE_PROPAGATION_TARGETS", _INTERNAL_TRACE_TARGETS_JSON)
430+
sentry_capture.start()
431+
432+
headers = _httpx_request_headers(_INTERNAL_URL)
433+
434+
assert _SENTRY_TRACE_HEADER in headers
435+
436+
def test_outbound_request_to_other_host_has_no_trace_headers_when_targets_set(
437+
self, sentry_capture: SentryCapture, monkeypatch: pytest.MonkeyPatch
438+
) -> None:
439+
"""A request to a host outside TRACE_PROPAGATION_TARGETS gets no ``sentry-trace`` or ``baggage`` header."""
440+
monkeypatch.setenv(f"{_SENTRY_PREFIX}TRACE_PROPAGATION_TARGETS", _INTERNAL_TRACE_TARGETS_JSON)
441+
sentry_capture.start()
442+
443+
headers = _httpx_request_headers(_PARTNER_URL)
444+
445+
assert _SENTRY_TRACE_HEADER not in headers
446+
assert _BAGGAGE_HEADER not in headers
447+
358448

359449
@pytest.mark.integration
360450
class TestSentrySettingsDsnValidation:

0 commit comments

Comments
 (0)