Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `opentelemetry-resource-detector-containerid`: make it more quiet on platforms without cgroups
([#3579](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3579))

### Added

- `opentelemetry-util-http` Added support for redacting specific url query string values and url credentials in instrumentations
([#3508](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3508))

## Version 1.34.0/0.55b0 (2025-06-04)

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def response_hook(span: Span, params: typing.Union[
)
from opentelemetry.trace import Span, SpanKind, TracerProvider, get_tracer
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials, sanitize_method
from opentelemetry.util.http import redact_url, sanitize_method

_UrlFilterT = typing.Optional[typing.Callable[[yarl.URL], str]]
_RequestHookT = typing.Optional[
Expand Down Expand Up @@ -311,9 +311,9 @@ async def on_request_start(
method = params.method
request_span_name = _get_span_name(method)
request_url = (
remove_url_credentials(trace_config_ctx.url_filter(params.url))
redact_url(trace_config_ctx.url_filter(params.url))
if callable(trace_config_ctx.url_filter)
else remove_url_credentials(str(params.url))
else redact_url(str(params.url))
)

span_attributes = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -762,16 +762,16 @@ async def do_request(url):
)
self.memory_exporter.clear()

def test_credential_removal(self):
def test_remove_sensitive_params(self):
trace_configs = [aiohttp_client.create_trace_config()]

app = HttpServerMock("test_credential_removal")
app = HttpServerMock("test_remove_sensitive_params")

@app.route("/status/200")
def index():
return "hello"

url = "http://username:password@localhost:5000/status/200"
url = "http://username:password@localhost:5000/status/200?Signature=secret"

with app.run("localhost", 5000):
with self.subTest(url=url):
Expand All @@ -793,7 +793,9 @@ async def do_request(url):
(StatusCode.UNSET, None),
{
HTTP_METHOD: "GET",
HTTP_URL: ("http://localhost:5000/status/200"),
HTTP_URL: (
"http://REDACTED:REDACTED@localhost:5000/status/200?Signature=REDACTED"
),
HTTP_STATUS_CODE: int(HTTPStatus.OK),
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def hello(request):
)
from opentelemetry.semconv.metrics import MetricInstruments
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import get_excluded_urls, remove_url_credentials
from opentelemetry.util.http import get_excluded_urls, redact_url

_duration_attrs = [
HTTP_METHOD,
Expand Down Expand Up @@ -148,6 +148,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
request.url.port,
str(request.url),
)

query_string = request.query_string
if query_string and http_url:
if isinstance(query_string, bytes):
Expand All @@ -161,7 +162,7 @@ def collect_request_attributes(request: web.Request) -> Dict:
HTTP_ROUTE: _get_view_func(request),
HTTP_FLAVOR: f"{request.version.major}.{request.version.minor}",
HTTP_TARGET: request.path,
HTTP_URL: remove_url_credentials(http_url),
HTTP_URL: redact_url(http_url),
}

http_method = request.method
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,46 @@ async def test_suppress_instrumentation(
await client.get("/test-path")

assert len(memory_exporter.get_finished_spans()) == 0


@pytest.mark.asyncio
async def test_remove_sensitive_params(tracer, aiohttp_server):
"""Test that sensitive information in URLs is properly redacted."""
_, memory_exporter = tracer

# Set up instrumentation
AioHttpServerInstrumentor().instrument()

# Create app with test route
app = aiohttp.web.Application()

async def handler(request):
return aiohttp.web.Response(text="hello")

app.router.add_get("/status/200", handler)

# Start the server
server = await aiohttp_server(app)

# Make request with sensitive data in URL
url = f"http://username:password@{server.host}:{server.port}/status/200?Signature=secret"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
assert response.status == 200
assert await response.text() == "hello"

# Verify redaction in span attributes
spans = memory_exporter.get_finished_spans()
assert len(spans) == 1

span = spans[0]
assert span.attributes[HTTP_METHOD] == "GET"
assert span.attributes[HTTP_STATUS_CODE] == 200
assert (
span.attributes[HTTP_URL]
== f"http://{server.host}:{server.port}/status/200?Signature=REDACTED"
)

# Clean up
AioHttpServerInstrumentor().uninstrument()
memory_exporter.clear()
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def client_response_hook(span: Span, scope: Scope, message: dict[str, Any]):
get_custom_headers,
normalise_request_header_name,
normalise_response_header_name,
remove_url_credentials,
redact_url,
sanitize_method,
)

Expand Down Expand Up @@ -375,7 +375,7 @@ def collect_request_attributes(
if _report_old(sem_conv_opt_in_mode):
_set_http_url(
result,
remove_url_credentials(http_url),
redact_url(http_url),
_StabilityMode.DEFAULT,
)
http_method = scope.get("method", "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1809,12 +1809,14 @@ def test_response_attributes_invalid_status_code(self):
otel_asgi.set_status_code(self.span, "Invalid Status Code")
self.assertEqual(self.span.set_status.call_count, 1)

def test_credential_removal(self):
def test_remove_sensitive_params(self):
self.scope["server"] = ("username:password@mock", 80)
self.scope["path"] = "/status/200"
self.scope["query_string"] = b"X-Goog-Signature=1234567890"
attrs = otel_asgi.collect_request_attributes(self.scope)
self.assertEqual(
attrs[SpanAttributes.HTTP_URL], "http://mock/status/200"
attrs[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?X-Goog-Signature=REDACTED",
)

def test_collect_target_attribute_missing(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ async def async_response_hook(span, request, response):
from opentelemetry.trace import SpanKind, Tracer, TracerProvider, get_tracer
from opentelemetry.trace.span import Span
from opentelemetry.trace.status import StatusCode
from opentelemetry.util.http import remove_url_credentials, sanitize_method
from opentelemetry.util.http import redact_url, sanitize_method

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -313,7 +313,7 @@ def _extract_parameters(
# In httpx >= 0.20.0, handle_request receives a Request object
request: httpx.Request = args[0]
method = request.method.encode()
url = httpx.URL(remove_url_credentials(str(request.url)))
url = httpx.URL(str(request.url))
headers = request.headers
stream = request.stream
extensions = request.extensions
Expand Down Expand Up @@ -382,7 +382,7 @@ def _apply_request_client_attributes_to_span(
)

# http semconv transition: http.url -> url.full
_set_http_url(span_attributes, str(url), semconv)
_set_http_url(span_attributes, redact_url(str(url)), semconv)

# Set HTTP method in metric labels
_set_http_method(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1301,12 +1301,26 @@ def test_basic(self):
self.assert_span(num_spans=1)
self.assert_metrics(num_metrics=1)

def test_credential_removal(self):
new_url = "http://username:password@mock/status/200"
def test_remove_sensitive_params(self):
new_url = "http://username:password@mock/status/200?sig=secret"
self.perform_request(new_url)
span = self.assert_span()

self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
actual_url = span.attributes[SpanAttributes.HTTP_URL]

if "@" in actual_url:
# If credentials are present, they must be redacted
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?sig=REDACTED",
)
else:
# If credentials are removed completely, the query string should still be redacted
self.assertIn(
"http://mock/status/200?sig=REDACTED",
actual_url,
f"Basic URL structure is incorrect: {actual_url}",
)


class TestAsyncIntegration(BaseTestCases.BaseManualTest):
Expand Down Expand Up @@ -1373,12 +1387,24 @@ def test_basic_multiple(self):
self.assert_span(num_spans=2)
self.assert_metrics(num_metrics=1)

def test_credential_removal(self):
new_url = "http://username:password@mock/status/200"
def test_remove_sensitive_params(self):
new_url = "http://username:password@mock/status/200?Signature=secret"
self.perform_request(new_url)
span = self.assert_span()

self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
actual_url = span.attributes[SpanAttributes.HTTP_URL]

if "@" in actual_url:
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?Signature=REDACTED",
)
else:
self.assertIn(
"http://mock/status/200?Signature=REDACTED",
actual_url,
f"If credentials are removed, the query string still should be redacted {actual_url}",
)


class TestSyncInstrumentationIntegration(BaseTestCases.BaseInstrumentorTest):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def response_hook(span, request_obj, response):
ExcludeList,
get_excluded_urls,
parse_excluded_urls,
remove_url_credentials,
redact_url,
sanitize_method,
)
from opentelemetry.util.http.httplib import set_ip_on_next_http_connection
Expand Down Expand Up @@ -232,7 +232,7 @@ def get_or_create_headers():
method = request.method
span_name = get_default_span_name(method)

url = remove_url_credentials(request.url)
url = redact_url(request.url)

span_attributes = {}
_set_http_method(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -686,12 +686,17 @@ def perform_request(url: str, session: requests.Session = None):
return requests.get(url, timeout=5)
return session.get(url)

def test_credential_removal(self):
new_url = "http://username:password@mock/status/200"
def test_remove_sensitive_params(self):
new_url = (
"http://username:password@mock/status/200?AWSAccessKeyId=secret"
)
self.perform_request(new_url)
span = self.assert_span()

self.assertEqual(span.attributes[HTTP_URL], self.URL)
self.assertEqual(
span.attributes[HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200?AWSAccessKeyId=REDACTED",
)

def test_if_headers_equals_none(self):
result = requests.get(self.URL, headers=None, timeout=5)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
HTTP_URL,
)
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
from opentelemetry.util.http import redact_url


def _normalize_request(args, kwargs):
Expand Down Expand Up @@ -79,7 +79,7 @@ def fetch_async(

if span.is_recording():
attributes = {
HTTP_URL: remove_url_credentials(request.url),
HTTP_URL: redact_url(request.url),
HTTP_METHOD: request.method,
}
for key, value in attributes.items():
Expand Down Expand Up @@ -165,7 +165,7 @@ def _finish_tracing_callback(
def _create_metric_attributes(response):
metric_attributes = {
HTTP_STATUS_CODE: response.code,
HTTP_URL: remove_url_credentials(response.request.url),
HTTP_URL: redact_url(response.request.url),
HTTP_METHOD: response.request.method,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,16 +500,16 @@ def test_response_headers(self):

set_global_response_propagator(orig)

def test_credential_removal(self):
app = HttpServerMock("test_credential_removal")
def test_remove_sensitive_params(self):
app = HttpServerMock("test_remove_sensitive_params")

@app.route("/status/200")
def index():
return "hello"

with app.run("localhost", 5000):
response = self.fetch(
"http://username:password@localhost:5000/status/200"
"http://username:password@localhost:5000/status/200?Signature=secret"
)
self.assertEqual(response.code, 200)

Expand All @@ -522,7 +522,7 @@ def index():
self.assertSpanHasAttributes(
client,
{
HTTP_URL: "http://localhost:5000/status/200",
HTTP_URL: "http://REDACTED:REDACTED@localhost:5000/status/200?Signature=REDACTED",
HTTP_METHOD: "GET",
HTTP_STATUS_CODE: 200,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def response_hook(span: Span, request: Request, response: HTTPResponse):
ExcludeList,
get_excluded_urls,
parse_excluded_urls,
remove_url_credentials,
redact_url,
sanitize_method,
)
from opentelemetry.util.types import Attributes
Expand Down Expand Up @@ -258,7 +258,7 @@ def _instrumented_open_call(

span_name = _get_span_name(method)

url = remove_url_credentials(url)
url = redact_url(url)

data = getattr(request, "data", None)
request_size = 0 if data is None else len(data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,14 +512,17 @@ def test_requests_timeout_exception(self, *_, **__):
span = self.assert_span()
self.assertEqual(span.status.status_code, StatusCode.ERROR)

def test_credential_removal(self):
def test_remove_sensitive_params(self):
url = "http://username:password@mock/status/200"

with self.assertRaises(Exception):
self.perform_request(url)

span = self.assert_span()
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://REDACTED:REDACTED@mock/status/200",
)

def test_hooks(self):
def request_hook(span, request_obj):
Expand Down
Loading