Skip to content

Commit 42c0ec0

Browse files
omgitsaheadcrabxrmx
authored andcommitted
feat: add ability to optionally disable internal HTTP send and receive spans (open-telemetry#2802)
1 parent b180bd9 commit 42c0ec0

File tree

4 files changed

+160
-69
lines changed

4 files changed

+160
-69
lines changed

Diff for: CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- `opentelemetry-instrumentation-kafka-python` Instrument temporary fork, kafka-python-ng
1515
inside kafka-python's instrumentation
1616
([#2537](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2537))
17+
- `opentelemetry-instrumentation-asgi`, `opentelemetry-instrumentation-fastapi` Add ability to disable internal HTTP send and receive spans
18+
([#2802](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2802))
1719

1820
### Breaking changes
1921

Diff for: instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py

+108-66
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ def get_default_span_details(scope: dict) -> Tuple[str, dict]:
483483

484484

485485
def _collect_target_attribute(
486-
scope: typing.Dict[str, typing.Any]
486+
scope: typing.Dict[str, typing.Any],
487487
) -> typing.Optional[str]:
488488
"""
489489
Returns the target path as defined by the Semantic Conventions.
@@ -529,6 +529,7 @@ class OpenTelemetryMiddleware:
529529
the current globally configured one is used.
530530
meter_provider: The optional meter provider to use. If omitted
531531
the current globally configured one is used.
532+
exclude_spans: Optionally exclude HTTP `send` and/or `receive` spans from the trace.
532533
"""
533534

534535
# pylint: disable=too-many-branches
@@ -547,6 +548,7 @@ def __init__(
547548
http_capture_headers_server_request: list[str] | None = None,
548549
http_capture_headers_server_response: list[str] | None = None,
549550
http_capture_headers_sanitize_fields: list[str] | None = None,
551+
exclude_spans: list[typing.Literal["receive", "send"]] | None = None,
550552
):
551553
# initialize semantic conventions opt-in if needed
552554
_OpenTelemetrySemanticConventionStability._initialize()
@@ -653,6 +655,12 @@ def __init__(
653655
)
654656
or []
655657
)
658+
self.exclude_receive_span = (
659+
"receive" in exclude_spans if exclude_spans else False
660+
)
661+
self.exclude_send_span = (
662+
"send" in exclude_spans if exclude_spans else False
663+
)
656664

657665
# pylint: disable=too-many-statements
658666
async def __call__(
@@ -796,8 +804,10 @@ async def __call__(
796804
span.end()
797805

798806
# pylint: enable=too-many-branches
799-
800807
def _get_otel_receive(self, server_span_name, scope, receive):
808+
if self.exclude_receive_span:
809+
return receive
810+
801811
@wraps(receive)
802812
async def otel_receive():
803813
with self.tracer.start_as_current_span(
@@ -821,6 +831,66 @@ async def otel_receive():
821831

822832
return otel_receive
823833

834+
def _set_send_span(
835+
self,
836+
server_span_name,
837+
scope,
838+
send,
839+
message,
840+
status_code,
841+
expecting_trailers,
842+
):
843+
"""Set send span attributes and status code."""
844+
with self.tracer.start_as_current_span(
845+
" ".join((server_span_name, scope["type"], "send"))
846+
) as send_span:
847+
if callable(self.client_response_hook):
848+
self.client_response_hook(send_span, scope, message)
849+
850+
if send_span.is_recording():
851+
if message["type"] == "http.response.start":
852+
expecting_trailers = message.get("trailers", False)
853+
send_span.set_attribute("asgi.event.type", message["type"])
854+
855+
if status_code:
856+
set_status_code(
857+
send_span,
858+
status_code,
859+
None,
860+
self._sem_conv_opt_in_mode,
861+
)
862+
return expecting_trailers
863+
864+
def _set_server_span(
865+
self, server_span, message, status_code, duration_attrs
866+
):
867+
"""Set server span attributes and status code."""
868+
if (
869+
server_span.is_recording()
870+
and server_span.kind == trace.SpanKind.SERVER
871+
and "headers" in message
872+
):
873+
custom_response_attributes = (
874+
collect_custom_headers_attributes(
875+
message,
876+
self.http_capture_headers_sanitize_fields,
877+
self.http_capture_headers_server_response,
878+
normalise_response_header_name,
879+
)
880+
if self.http_capture_headers_server_response
881+
else {}
882+
)
883+
if len(custom_response_attributes) > 0:
884+
server_span.set_attributes(custom_response_attributes)
885+
886+
if status_code:
887+
set_status_code(
888+
server_span,
889+
status_code,
890+
duration_attrs,
891+
self._sem_conv_opt_in_mode,
892+
)
893+
824894
def _get_otel_send(
825895
self,
826896
server_span,
@@ -834,74 +904,46 @@ def _get_otel_send(
834904
@wraps(send)
835905
async def otel_send(message: dict[str, Any]):
836906
nonlocal expecting_trailers
837-
with self.tracer.start_as_current_span(
838-
" ".join((server_span_name, scope["type"], "send"))
839-
) as send_span:
840-
if callable(self.client_response_hook):
841-
self.client_response_hook(send_span, scope, message)
842907

843-
status_code = None
844-
if message["type"] == "http.response.start":
845-
status_code = message["status"]
846-
elif message["type"] == "websocket.send":
847-
status_code = 200
848-
849-
if send_span.is_recording():
850-
if message["type"] == "http.response.start":
851-
expecting_trailers = message.get("trailers", False)
852-
send_span.set_attribute("asgi.event.type", message["type"])
853-
if (
854-
server_span.is_recording()
855-
and server_span.kind == trace.SpanKind.SERVER
856-
and "headers" in message
857-
):
858-
custom_response_attributes = (
859-
collect_custom_headers_attributes(
860-
message,
861-
self.http_capture_headers_sanitize_fields,
862-
self.http_capture_headers_server_response,
863-
normalise_response_header_name,
864-
)
865-
if self.http_capture_headers_server_response
866-
else {}
867-
)
868-
if len(custom_response_attributes) > 0:
869-
server_span.set_attributes(
870-
custom_response_attributes
871-
)
872-
if status_code:
873-
# We record metrics only once
874-
set_status_code(
875-
server_span,
876-
status_code,
877-
duration_attrs,
878-
self._sem_conv_opt_in_mode,
879-
)
880-
set_status_code(
881-
send_span,
882-
status_code,
883-
None,
884-
self._sem_conv_opt_in_mode,
885-
)
908+
status_code = None
909+
if message["type"] == "http.response.start":
910+
status_code = message["status"]
911+
elif message["type"] == "websocket.send":
912+
status_code = 200
886913

887-
propagator = get_global_response_propagator()
888-
if propagator:
889-
propagator.inject(
890-
message,
891-
context=set_span_in_context(
892-
server_span, trace.context_api.Context()
893-
),
894-
setter=asgi_setter,
895-
)
914+
if not self.exclude_send_span:
915+
expecting_trailers = self._set_send_span(
916+
server_span_name,
917+
scope,
918+
send,
919+
message,
920+
status_code,
921+
expecting_trailers,
922+
)
896923

897-
content_length = asgi_getter.get(message, "content-length")
898-
if content_length:
899-
try:
900-
self.content_length_header = int(content_length[0])
901-
except ValueError:
902-
pass
924+
self._set_server_span(
925+
server_span, message, status_code, duration_attrs
926+
)
927+
928+
propagator = get_global_response_propagator()
929+
if propagator:
930+
propagator.inject(
931+
message,
932+
context=set_span_in_context(
933+
server_span, trace.context_api.Context()
934+
),
935+
setter=asgi_setter,
936+
)
937+
938+
content_length = asgi_getter.get(message, "content-length")
939+
if content_length:
940+
try:
941+
self.content_length_header = int(content_length[0])
942+
except ValueError:
943+
pass
944+
945+
await send(message)
903946

904-
await send(message)
905947
# pylint: disable=too-many-boolean-expressions
906948
if (
907949
not expecting_trailers

Diff for: instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py

+24
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,30 @@ async def test_background_execution(self):
566566
_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S * 10**9,
567567
)
568568

569+
async def test_exclude_internal_spans(self):
570+
"""Test that internal spans are excluded from the emitted spans when
571+
the `exclude_receive_span` or `exclude_send_span` attributes are set.
572+
"""
573+
cases = [
574+
(["receive", "send"], ["GET / http receive", "GET / http send"]),
575+
(["send"], ["GET / http send"]),
576+
(["receive"], ["GET / http receive"]),
577+
([], []),
578+
]
579+
for exclude_spans, excluded_spans in cases:
580+
self.memory_exporter.clear()
581+
app = otel_asgi.OpenTelemetryMiddleware(
582+
simple_asgi, exclude_spans=exclude_spans
583+
)
584+
self.seed_app(app)
585+
await self.send_default_request()
586+
await self.get_all_output()
587+
span_list = self.memory_exporter.get_finished_spans()
588+
self.assertTrue(span_list)
589+
for span in span_list:
590+
for excluded_span in excluded_spans:
591+
self.assertNotEqual(span.name, excluded_span)
592+
569593
async def test_trailers(self):
570594
"""Test that trailers are emitted as expected and that the server span is ended
571595
BEFORE the background task is finished."""

Diff for: instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py

+26-3
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
179179
from __future__ import annotations
180180

181181
import logging
182-
from typing import Collection
182+
from typing import Collection, Literal
183183

184184
import fastapi
185185
from starlette.routing import Match
@@ -222,7 +222,7 @@ class FastAPIInstrumentor(BaseInstrumentor):
222222

223223
@staticmethod
224224
def instrument_app(
225-
app: fastapi.FastAPI,
225+
app,
226226
server_request_hook: ServerRequestHook = None,
227227
client_request_hook: ClientRequestHook = None,
228228
client_response_hook: ClientResponseHook = None,
@@ -232,8 +232,28 @@ def instrument_app(
232232
http_capture_headers_server_request: list[str] | None = None,
233233
http_capture_headers_server_response: list[str] | None = None,
234234
http_capture_headers_sanitize_fields: list[str] | None = None,
235+
exclude_spans: list[Literal["receive", "send"]] | None = None,
235236
):
236-
"""Instrument an uninstrumented FastAPI application."""
237+
"""Instrument an uninstrumented FastAPI application.
238+
239+
Args:
240+
app: The fastapi ASGI application callable to forward requests to.
241+
server_request_hook: Optional callback which is called with the server span and ASGI
242+
scope object for every incoming request.
243+
client_request_hook: Optional callback which is called with the internal span, and ASGI
244+
scope and event which are sent as dictionaries for when the method receive is called.
245+
client_response_hook: Optional callback which is called with the internal span, and ASGI
246+
scope and event which are sent as dictionaries for when the method send is called.
247+
tracer_provider: The optional tracer provider to use. If omitted
248+
the current globally configured one is used.
249+
meter_provider: The optional meter provider to use. If omitted
250+
the current globally configured one is used.
251+
excluded_urls: Optional comma delimited string of regexes to match URLs that should not be traced.
252+
http_capture_headers_server_request: Optional list of HTTP headers to capture from the request.
253+
http_capture_headers_server_response: Optional list of HTTP headers to capture from the response.
254+
http_capture_headers_sanitize_fields: Optional list of HTTP headers to sanitize.
255+
exclude_spans: Optionally exclude HTTP `send` and/or `receive` spans from the trace.
256+
"""
237257
if not hasattr(app, "_is_instrumented_by_opentelemetry"):
238258
app._is_instrumented_by_opentelemetry = False
239259

@@ -273,6 +293,7 @@ def instrument_app(
273293
http_capture_headers_server_request=http_capture_headers_server_request,
274294
http_capture_headers_server_response=http_capture_headers_server_response,
275295
http_capture_headers_sanitize_fields=http_capture_headers_sanitize_fields,
296+
exclude_spans=exclude_spans,
276297
)
277298
app._is_instrumented_by_opentelemetry = True
278299
if app not in _InstrumentedFastAPI._instrumented_fastapi_apps:
@@ -323,6 +344,7 @@ def _instrument(self, **kwargs):
323344
else parse_excluded_urls(_excluded_urls)
324345
)
325346
_InstrumentedFastAPI._meter_provider = kwargs.get("meter_provider")
347+
_InstrumentedFastAPI._exclude_spans = kwargs.get("exclude_spans")
326348
fastapi.FastAPI = _InstrumentedFastAPI
327349

328350
def _uninstrument(self, **kwargs):
@@ -373,6 +395,7 @@ def __init__(self, *args, **kwargs):
373395
http_capture_headers_server_request=_InstrumentedFastAPI._http_capture_headers_server_request,
374396
http_capture_headers_server_response=_InstrumentedFastAPI._http_capture_headers_server_response,
375397
http_capture_headers_sanitize_fields=_InstrumentedFastAPI._http_capture_headers_sanitize_fields,
398+
exclude_spans=_InstrumentedFastAPI._exclude_spans,
376399
)
377400
self._is_instrumented_by_opentelemetry = True
378401
_InstrumentedFastAPI._instrumented_fastapi_apps.add(self)

0 commit comments

Comments
 (0)