Skip to content

Commit b7afdd5

Browse files
authored
Disable ASGI send/receive spans by default (#371)
1 parent ffe0c25 commit b7afdd5

File tree

14 files changed

+515
-38
lines changed

14 files changed

+515
-38
lines changed

logfire-api/logfire_api/_internal/exporters/processor_wrapper.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from ..constants import ATTRIBUTES_LOG_LEVEL_NUM_KEY as ATTRIBUTES_LOG_LEVEL_NUM_KEY, ATTRIBUTES_MESSAGE_KEY as ATTRIBUTES_MESSAGE_KEY, ATTRIBUTES_MESSAGE_TEMPLATE_KEY as ATTRIBUTES_MESSAGE_TEMPLATE_KEY, ATTRIBUTES_SPAN_TYPE_KEY as ATTRIBUTES_SPAN_TYPE_KEY, LEVEL_NUMBERS as LEVEL_NUMBERS, PENDING_SPAN_NAME_SUFFIX as PENDING_SPAN_NAME_SUFFIX, log_level_attributes as log_level_attributes
22
from ..db_statement_summary import message_from_db_statement as message_from_db_statement
33
from ..scrubbing import BaseScrubber as BaseScrubber
4-
from ..utils import ReadableSpanDict as ReadableSpanDict, is_instrumentation_suppressed as is_instrumentation_suppressed, span_to_dict as span_to_dict, truncate_string as truncate_string
4+
from ..utils import ReadableSpanDict as ReadableSpanDict, is_asgi_send_receive_span_name as is_asgi_send_receive_span_name, is_instrumentation_suppressed as is_instrumentation_suppressed, span_to_dict as span_to_dict, truncate_string as truncate_string
55
from .wrapper import WrapperSpanProcessor as WrapperSpanProcessor
66
from _typeshed import Incomplete
77
from opentelemetry import context
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from dataclasses import dataclass
2+
from logfire import Logfire as Logfire
3+
from logfire._internal.utils import is_asgi_send_receive_span_name as is_asgi_send_receive_span_name
4+
from opentelemetry.context import Context
5+
from opentelemetry.trace import Span, Tracer, TracerProvider
6+
from typing import Any
7+
8+
def tweak_asgi_spans_tracer_provider(logfire_instance: Logfire, record_send_receive: bool) -> TracerProvider:
9+
"""If record_send_receive is False, return a TracerProvider that skips spans for ASGI send and receive events."""
10+
11+
@dataclass
12+
class TweakAsgiTracerProvider(TracerProvider):
13+
tracer_provider: TracerProvider
14+
def get_tracer(self, *args: Any, **kwargs: Any) -> Tracer: ...
15+
16+
@dataclass
17+
class TweakAsgiSpansTracer(Tracer):
18+
tracer: Tracer
19+
def start_span(self, name: str, context: Context | None = None, *args: Any, **kwargs: Any) -> Span: ...
20+
start_as_current_span = ...

logfire-api/logfire_api/_internal/integrations/fastapi.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from ..main import Logfire as Logfire
22
from ..stack_info import StackInfo as StackInfo, get_code_object_info as get_code_object_info
33
from ..utils import maybe_capture_server_headers as maybe_capture_server_headers
4+
from .asgi import tweak_asgi_spans_tracer_provider as tweak_asgi_spans_tracer_provider
45
from _typeshed import Incomplete
56
from fastapi import FastAPI
67
from starlette.requests import Request
@@ -9,7 +10,7 @@ from typing import Any, Awaitable, Callable, ContextManager, Iterable
910

1011
def find_mounted_apps(app: FastAPI) -> list[FastAPI]:
1112
"""Fetch all sub-apps mounted to a FastAPI app, including nested sub-apps."""
12-
def instrument_fastapi(logfire_instance: Logfire, app: FastAPI, *, capture_headers: bool = False, request_attributes_mapper: Callable[[Request | WebSocket, dict[str, Any]], dict[str, Any] | None] | None = None, use_opentelemetry_instrumentation: bool = True, excluded_urls: str | Iterable[str] | None = None, **opentelemetry_kwargs: Any) -> ContextManager[None]:
13+
def instrument_fastapi(logfire_instance: Logfire, app: FastAPI, *, capture_headers: bool = False, request_attributes_mapper: Callable[[Request | WebSocket, dict[str, Any]], dict[str, Any] | None] | None = None, use_opentelemetry_instrumentation: bool = True, excluded_urls: str | Iterable[str] | None = None, record_send_receive: bool = False, **opentelemetry_kwargs: Any) -> ContextManager[None]:
1314
"""Instrument a FastAPI app so that spans and logs are automatically created for each request.
1415
1516
See `Logfire.instrument_fastapi` for more details.

logfire-api/logfire_api/_internal/integrations/starlette.pyi

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,16 @@
1+
from logfire import Logfire as Logfire
2+
from logfire._internal.integrations.asgi import tweak_asgi_spans_tracer_provider as tweak_asgi_spans_tracer_provider
13
from logfire._internal.utils import maybe_capture_server_headers as maybe_capture_server_headers
2-
from opentelemetry.trace import Span
4+
from opentelemetry.instrumentation.asgi.types import ClientRequestHook, ClientResponseHook, ServerRequestHook
35
from starlette.applications import Starlette
4-
from typing import Any
5-
from typing_extensions import Protocol, TypedDict, Unpack
6-
7-
class ServerRequestHook(Protocol):
8-
def __call__(self, span: Span, scope: dict[str, Any]): ...
9-
10-
class ClientRequestHook(Protocol):
11-
def __call__(self, span: Span, scope: dict[str, Any]): ...
12-
13-
class ClientResponseHook(Protocol):
14-
def __call__(self, span: Span, message: dict[str, Any]): ...
6+
from typing_extensions import TypedDict, Unpack
157

168
class StarletteInstrumentKwargs(TypedDict, total=False):
179
server_request_hook: ServerRequestHook | None
1810
client_request_hook: ClientRequestHook | None
1911
client_response_hook: ClientResponseHook | None
2012

21-
def instrument_starlette(app: Starlette, *, capture_headers: bool = False, **kwargs: Unpack[StarletteInstrumentKwargs]):
13+
def instrument_starlette(logfire_instance: Logfire, app: Starlette, *, record_send_receive: bool = False, capture_headers: bool = False, **kwargs: Unpack[StarletteInstrumentKwargs]):
2214
"""Instrument `app` so that spans are automatically created for each request.
2315
2416
See the `Logfire.instrument_starlette` method for details.

logfire-api/logfire_api/_internal/main.pyi

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ class Logfire:
368368
Otherwise, the first time(s) each function is called, it will be timed but not traced.
369369
Only after the function has run for at least `min_duration` will it be traced in subsequent calls.
370370
"""
371-
def instrument_fastapi(self, app: FastAPI, *, capture_headers: bool = False, request_attributes_mapper: Callable[[Request | WebSocket, dict[str, Any]], dict[str, Any] | None] | None = None, use_opentelemetry_instrumentation: bool = True, excluded_urls: str | Iterable[str] | None = None, **opentelemetry_kwargs: Any) -> ContextManager[None]:
371+
def instrument_fastapi(self, app: FastAPI, *, capture_headers: bool = False, request_attributes_mapper: Callable[[Request | WebSocket, dict[str, Any]], dict[str, Any] | None] | None = None, use_opentelemetry_instrumentation: bool = True, excluded_urls: str | Iterable[str] | None = None, record_send_receive: bool = False, **opentelemetry_kwargs: Any) -> ContextManager[None]:
372372
"""Instrument a FastAPI app so that spans and logs are automatically created for each request.
373373
374374
Args:
@@ -398,6 +398,10 @@ class Logfire:
398398
will also instrument the app.
399399
400400
See [OpenTelemetry FastAPI Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html).
401+
record_send_receive: Set to True to allow the OpenTelemetry ASGI to create send/receive spans.
402+
These are disabled by default to reduce overhead and the number of spans created,
403+
since many can be created for a single request, and they are not often useful.
404+
If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI.
401405
opentelemetry_kwargs: Additional keyword arguments to pass to the OpenTelemetry FastAPI instrumentation.
402406
403407
Returns:
@@ -588,11 +592,16 @@ class Logfire:
588592
[OpenTelemetry Flask Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/flask/flask.html)
589593
library, specifically `FlaskInstrumentor().instrument_app()`, to which it passes `**kwargs`.
590594
"""
591-
def instrument_starlette(self, app: Starlette, *, capture_headers: bool = False, **kwargs: Unpack[StarletteInstrumentKwargs]) -> None:
595+
def instrument_starlette(self, app: Starlette, *, capture_headers: bool = False, record_send_receive: bool = False, **kwargs: Unpack[StarletteInstrumentKwargs]) -> None:
592596
"""Instrument `app` so that spans are automatically created for each request.
593597
594598
Set `capture_headers` to `True` to capture all request and response headers.
595599
600+
Set `record_send_receive` to `True` to allow the OpenTelemetry ASGI to create send/receive spans.
601+
These are disabled by default to reduce overhead and the number of spans created,
602+
since many can be created for a single request, and they are not often useful.
603+
If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI.
604+
596605
Uses the
597606
[OpenTelemetry Starlette Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/starlette/starlette.html)
598607
library, specifically `StarletteInstrumentor.instrument_app()`, to which it passes `**kwargs`.

logfire-api/logfire_api/_internal/utils.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,4 @@ def suppress_instrumentation() -> Generator[None, None, None]:
8787
def log_internal_error() -> None: ...
8888
def handle_internal_errors() -> Generator[None, None, None]: ...
8989
def maybe_capture_server_headers(capture: bool): ...
90+
def is_asgi_send_receive_span_name(name: str) -> bool: ...

logfire/_internal/exporters/processor_wrapper.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121
)
2222
from ..db_statement_summary import message_from_db_statement
2323
from ..scrubbing import BaseScrubber
24-
from ..utils import ReadableSpanDict, is_instrumentation_suppressed, span_to_dict, truncate_string
24+
from ..utils import (
25+
ReadableSpanDict,
26+
is_asgi_send_receive_span_name,
27+
is_instrumentation_suppressed,
28+
span_to_dict,
29+
truncate_string,
30+
)
2531
from .wrapper import WrapperSpanProcessor
2632

2733

@@ -143,7 +149,7 @@ def _is_asgi_send_receive_span(name: str, instrumentation_scope: Instrumentation
143149
'opentelemetry.instrumentation.starlette',
144150
'opentelemetry.instrumentation.fastapi',
145151
)
146-
) and (name.endswith((' http send', ' http receive', ' websocket send', ' websocket receive')))
152+
) and is_asgi_send_receive_span_name(name)
147153

148154

149155
def _tweak_http_spans(span: ReadableSpanDict):
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING, Any
5+
6+
from opentelemetry.context import Context
7+
from opentelemetry.sdk.trace import Tracer as SDKTracer
8+
from opentelemetry.trace import NonRecordingSpan, Span, Tracer, TracerProvider
9+
from opentelemetry.trace.propagation import get_current_span
10+
11+
from logfire._internal.utils import is_asgi_send_receive_span_name
12+
13+
if TYPE_CHECKING:
14+
from logfire import Logfire
15+
16+
17+
def tweak_asgi_spans_tracer_provider(logfire_instance: Logfire, record_send_receive: bool) -> TracerProvider:
18+
"""If record_send_receive is False, return a TracerProvider that skips spans for ASGI send and receive events."""
19+
tracer_provider = logfire_instance.config.get_tracer_provider()
20+
if record_send_receive:
21+
return tracer_provider
22+
else:
23+
return TweakAsgiTracerProvider(tracer_provider)
24+
25+
26+
@dataclass
27+
class TweakAsgiTracerProvider(TracerProvider):
28+
tracer_provider: TracerProvider
29+
30+
def get_tracer(self, *args: Any, **kwargs: Any) -> Tracer:
31+
return TweakAsgiSpansTracer(self.tracer_provider.get_tracer(*args, **kwargs))
32+
33+
34+
@dataclass
35+
class TweakAsgiSpansTracer(Tracer):
36+
tracer: Tracer
37+
38+
def start_span(self, name: str, context: Context | None = None, *args: Any, **kwargs: Any) -> Span:
39+
if is_asgi_send_receive_span_name(name):
40+
# These are the noisy spans we want to skip.
41+
# Create a no-op span with the same SpanContext as the current span.
42+
# This means that any spans created within will have the current span as their parent,
43+
# as if this span didn't exist at all.
44+
return NonRecordingSpan(get_current_span(context).get_span_context())
45+
46+
return self.tracer.start_span(name, context, *args, **kwargs)
47+
48+
# This means that `with start_as_current_span(...):`
49+
# is roughly equivalent to `with use_span(start_span(...)):`
50+
start_as_current_span = SDKTracer.start_as_current_span

logfire/_internal/integrations/fastapi.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ..main import Logfire
1818
from ..stack_info import StackInfo, get_code_object_info
1919
from ..utils import maybe_capture_server_headers
20+
from .asgi import tweak_asgi_spans_tracer_provider
2021

2122
try:
2223
from opentelemetry.instrumentation.asgi import get_host_port_url_tuple # type: ignore
@@ -58,6 +59,7 @@ def instrument_fastapi(
5859
| None = None,
5960
use_opentelemetry_instrumentation: bool = True,
6061
excluded_urls: str | Iterable[str] | None = None,
62+
record_send_receive: bool = False,
6163
**opentelemetry_kwargs: Any,
6264
) -> ContextManager[None]:
6365
"""Instrument a FastAPI app so that spans and logs are automatically created for each request.
@@ -71,7 +73,12 @@ def instrument_fastapi(
7173

7274
if use_opentelemetry_instrumentation: # pragma: no branch
7375
maybe_capture_server_headers(capture_headers)
74-
FastAPIInstrumentor.instrument_app(app, excluded_urls=excluded_urls, **opentelemetry_kwargs) # type: ignore
76+
FastAPIInstrumentor.instrument_app( # type: ignore
77+
app,
78+
excluded_urls=excluded_urls,
79+
tracer_provider=tweak_asgi_spans_tracer_provider(logfire_instance, record_send_receive),
80+
**opentelemetry_kwargs,
81+
)
7582

7683
registry = patch_fastapi()
7784
if app in registry: # pragma: no cover
Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,39 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from typing import TYPE_CHECKING
44

55
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
66
from starlette.applications import Starlette
77

8+
from logfire import Logfire
9+
from logfire._internal.integrations.asgi import tweak_asgi_spans_tracer_provider
810
from logfire._internal.utils import maybe_capture_server_headers
911

1012
if TYPE_CHECKING:
11-
from opentelemetry.trace import Span
12-
from typing_extensions import Protocol, TypedDict, Unpack
13-
14-
class ServerRequestHook(Protocol):
15-
def __call__(self, span: Span, scope: dict[str, Any]): ...
16-
17-
class ClientRequestHook(Protocol):
18-
def __call__(self, span: Span, scope: dict[str, Any]): ...
19-
20-
class ClientResponseHook(Protocol):
21-
def __call__(self, span: Span, message: dict[str, Any]): ...
13+
from opentelemetry.instrumentation.asgi.types import ClientRequestHook, ClientResponseHook, ServerRequestHook
14+
from typing_extensions import TypedDict, Unpack
2215

2316
class StarletteInstrumentKwargs(TypedDict, total=False):
2417
server_request_hook: ServerRequestHook | None
2518
client_request_hook: ClientRequestHook | None
2619
client_response_hook: ClientResponseHook | None
2720

2821

29-
def instrument_starlette(app: Starlette, *, capture_headers: bool = False, **kwargs: Unpack[StarletteInstrumentKwargs]):
22+
def instrument_starlette(
23+
logfire_instance: Logfire,
24+
app: Starlette,
25+
*,
26+
record_send_receive: bool = False,
27+
capture_headers: bool = False,
28+
**kwargs: Unpack[StarletteInstrumentKwargs],
29+
):
3030
"""Instrument `app` so that spans are automatically created for each request.
3131
3232
See the `Logfire.instrument_starlette` method for details.
3333
"""
3434
maybe_capture_server_headers(capture_headers)
35-
StarletteInstrumentor().instrument_app(app, **kwargs) # type: ignore[reportUnknownMemberType]
35+
StarletteInstrumentor().instrument_app( # type: ignore[reportUnknownMemberType]
36+
app,
37+
tracer_provider=tweak_asgi_spans_tracer_provider(logfire_instance, record_send_receive),
38+
**kwargs,
39+
)

0 commit comments

Comments
 (0)