Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/instana/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def boot_agent() -> None:
flask, # noqa: F401
# gevent_inst, # noqa: F401
grpcio, # noqa: F401
httpx, # noqa: F401
logging, # noqa: F401
mysqlclient, # noqa: F401
pep0249, # noqa: F401
Expand Down
129 changes: 129 additions & 0 deletions src/instana/instrumentation/httpx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# (c) Copyright IBM Corp. 2025

try:
import httpx
import wrapt
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple, Optional
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import SpanKind

from instana.log import logger
from instana.propagators.format import Format
from instana.singletons import agent
from instana.util.secrets import strip_secrets_from_query
from instana.util.traceutils import (
extract_custom_headers,
get_tracer_tuple,
tracing_is_off,
)

if TYPE_CHECKING:
from instana.span.span import InstanaSpan

def _set_request_span_attributes(
span: "InstanaSpan",
request: httpx.Request,
) -> None:
try:
url = request.url

# Strip any secrets from potential query params
if url.query:
formatted_query = strip_secrets_from_query(
str(url.query, encoding="utf-8"),
agent.options.secrets_matcher,
agent.options.secrets_list,
)
span.set_attribute("http.params", formatted_query)

url_str = f"{url.scheme}://{url.host}"
if url.port:
url_str += f":{url.port}"
url_str += f"{url.path}"

span.set_attribute(SpanAttributes.HTTP_URL, url_str)
span.set_attribute(SpanAttributes.HTTP_HOST, url.host)
span.set_attribute(SpanAttributes.HTTP_METHOD, request.method)
span.set_attribute("http.path", url.path)

extract_custom_headers(span, request.headers)
except Exception:
logger.debug("httpx _set_request_span_attributes error: ", exc_info=True)

def _set_response_span_attributes(
span: "InstanaSpan",
response: Optional[httpx.Response] = None,
) -> None:
try:
if response.headers:
extract_custom_headers(span, response.headers)

status_code = response.status_code
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code)
if 500 <= status_code:
span.mark_as_errored()
except Exception:
logger.debug("httpx _set_request_span_attributes error: ", exc_info=True)

@wrapt.patch_function_wrapper("httpx", "HTTPTransport.handle_request")
def handle_request_with_instana(
wrapped: Callable[..., "httpx.HTTPTransport.handle_request"],
instance: httpx.HTTPTransport,
args: Tuple[int, str, Tuple[Any, ...]],
kwargs: Dict[str, Any],
) -> httpx.Response:
# If we're not tracing, just return
if tracing_is_off():
return wrapped(*args, **kwargs)

tracer, parent_span, _ = get_tracer_tuple()
parent_context = parent_span.get_span_context() if parent_span else None

with tracer.start_as_current_span(
"httpx", span_context=parent_context, kind=SpanKind.CLIENT
) as span:
try:
request = args[0]
_set_request_span_attributes(span, request)
tracer.inject(span.context, Format.HTTP_HEADERS, request.headers)

response = wrapped(*args, **kwargs)
_set_response_span_attributes(span, response)
except Exception as e:
span.record_exception(e)
else:
return response

@wrapt.patch_function_wrapper("httpx", "AsyncHTTPTransport.handle_async_request")
async def handle_async_request_with_instana(
wrapped: Callable[..., "httpx.AsyncHTTPTransport.handle_async_request"],
instance: httpx.AsyncHTTPTransport,
args: Tuple[int, str, Tuple[Any, ...]],
kwargs: Dict[str, Any],
) -> httpx.Response:
# If we're not tracing, just return
if tracing_is_off():
return await wrapped(*args, **kwargs)

tracer, parent_span, _ = get_tracer_tuple()
parent_context = parent_span.get_span_context() if parent_span else None

with tracer.start_as_current_span(
"httpx", span_context=parent_context, kind=SpanKind.CLIENT
) as span:
try:
request = args[0]
_set_request_span_attributes(span, request)
tracer.inject(span.context, Format.HTTP_HEADERS, request.headers)

response = await wrapped(*args, **kwargs)
_set_response_span_attributes(span, response)
except Exception as e:
span.record_exception(e)
else:
return response

logger.debug("Instrumenting httpx")

except ImportError:
pass
2 changes: 2 additions & 0 deletions src/instana/span/kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"aiohttp-server",
"django",
"http",
"httpx",
"tornado-client",
"tornado-server",
"urllib3",
Expand Down Expand Up @@ -43,6 +44,7 @@
"celery-client",
"couchbase",
"dynamodb",
"httpx",
"log",
"memcache",
"mongo",
Expand Down
11 changes: 5 additions & 6 deletions src/instana/span/registered_span.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@

from instana.log import logger
from instana.span.base_span import BaseSpan
from instana.span.kind import (
ENTRY_SPANS,
EXIT_SPANS,
HTTP_SPANS,
LOCAL_SPANS,
)
from instana.span.kind import ENTRY_SPANS, EXIT_SPANS, HTTP_SPANS, LOCAL_SPANS

if TYPE_CHECKING:
from instana.span.span import InstanaSpan
Expand Down Expand Up @@ -58,6 +53,10 @@ def __init__(
if "amqp" in span.name:
self.n = "amqp"

# unify the span name for httpx (and future exit HTTP spans)
if "httpx" in span.name:
self.n = "http"

# Logic to store custom attributes for registered spans (not used yet)
if len(span.attributes) > 0:
self.data["sdk"]["custom"]["tags"] = self._validate_attributes(
Expand Down
2 changes: 1 addition & 1 deletion src/instana/util/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def strip_secrets_from_query(qp, matcher, kwlist):
return qp

# If there are no key=values, then just return
if not '=' in qp:
if '=' not in qp:
return qp

if '?' in qp:
Expand Down
Loading
Loading