Skip to content

Commit bcd390f

Browse files
authored
Allow removing extra FastAPI spans (#1258)
1 parent da7c080 commit bcd390f

File tree

3 files changed

+394
-213
lines changed

3 files changed

+394
-213
lines changed

logfire/_internal/integrations/fastapi.py

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import inspect
55
from collections.abc import Awaitable, Iterable
66
from contextlib import AbstractContextManager, contextmanager
7+
from datetime import datetime, timezone
78
from functools import lru_cache
89
from typing import Any, Callable
910
from weakref import WeakKeyDictionary
@@ -17,7 +18,8 @@
1718
from starlette.responses import Response
1819
from starlette.websockets import WebSocket
1920

20-
from ..main import Logfire, set_user_attributes_on_raw_span
21+
from ..constants import ONE_SECOND_IN_NANOSECONDS
22+
from ..main import Logfire, NoopSpan, set_user_attributes_on_raw_span
2123
from ..stack_info import StackInfo, get_code_object_info
2224
from ..utils import handle_internal_errors, maybe_capture_server_headers
2325

@@ -61,6 +63,7 @@ def instrument_fastapi(
6163
| None = None,
6264
excluded_urls: str | Iterable[str] | None = None,
6365
record_send_receive: bool = False,
66+
extra_spans: bool = True,
6467
**opentelemetry_kwargs: Any,
6568
) -> AbstractContextManager[None]:
6669
"""Instrument a FastAPI app so that spans and logs are automatically created for each request.
@@ -96,6 +99,7 @@ def instrument_fastapi(
9699
registry[_app] = FastAPIInstrumentation(
97100
logfire_instance,
98101
request_attributes_mapper or _default_request_attributes_mapper,
102+
extra_spans=extra_spans,
99103
)
100104

101105
@contextmanager
@@ -158,16 +162,39 @@ def __init__(
158162
],
159163
dict[str, Any] | None,
160164
],
165+
extra_spans: bool,
161166
):
162167
self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='fastapi')
168+
self.timestamp_generator = self.logfire_instance.config.advanced.ns_timestamp_generator
163169
self.request_attributes_mapper = request_attributes_mapper
170+
self.extra_spans = extra_spans
171+
172+
@contextmanager
173+
def pseudo_span(self, namespace: str, root_span: Span):
174+
"""Record start and end timestamps in the root span, and possibly exceptions."""
175+
176+
def set_timestamp(attribute_name: str):
177+
dt = datetime.fromtimestamp(self.timestamp_generator() / ONE_SECOND_IN_NANOSECONDS, tz=timezone.utc)
178+
value = dt.strftime('%Y-%m-%dT%H:%M:%S.%fZ')
179+
root_span.set_attribute(f'fastapi.{namespace}.{attribute_name}', value)
180+
181+
set_timestamp('start_timestamp')
182+
try:
183+
try:
184+
yield
185+
finally:
186+
# Record the end timestamp before recording exceptions.
187+
set_timestamp('end_timestamp')
188+
except Exception as exc:
189+
root_span.record_exception(exc)
190+
raise
164191

165192
async def solve_dependencies(self, request: Request | WebSocket, original: Awaitable[Any]) -> Any:
166193
root_span = request.scope.get(LOGFIRE_SPAN_SCOPE_KEY)
167194
if not (root_span and root_span.is_recording()):
168195
return await original
169196

170-
with self.logfire_instance.span('FastAPI arguments') as span:
197+
with self.logfire_instance.span('FastAPI arguments') if self.extra_spans else NoopSpan() as span:
171198
with handle_internal_errors:
172199
if isinstance(request, Request): # pragma: no branch
173200
span.set_attribute('http.method', request.method)
@@ -180,7 +207,8 @@ async def solve_dependencies(self, request: Request | WebSocket, original: Await
180207
set_user_attributes_on_raw_span(root_span, fastapi_route_attributes)
181208
span.set_attributes(fastapi_route_attributes)
182209

183-
result: Any = await original
210+
with self.pseudo_span('arguments', root_span):
211+
result: Any = await original
184212

185213
with handle_internal_errors:
186214
solved_values: dict[str, Any]
@@ -228,7 +256,8 @@ def solved_with_new_values(new_values: dict[str, Any]) -> Any:
228256

229257
# request_attributes_mapper may have removed the errors, so we need .get() here.
230258
if attributes.get('errors'):
231-
span.set_level('error')
259+
# Errors should imply a 422 response. 4xx errors are warnings, not errors.
260+
span.set_level('warn')
232261

233262
span.set_attributes(attributes)
234263
for key in ('values', 'errors'):
@@ -246,20 +275,31 @@ async def run_endpoint_function(
246275
values: dict[str, Any],
247276
**kwargs: Any,
248277
) -> Any:
249-
callback = inspect.unwrap(dependant.call)
250-
code = getattr(callback, '__code__', None)
251-
stack_info: StackInfo = get_code_object_info(code) if code else {}
252-
with self.logfire_instance.span(
253-
'{method} {http.route} ({code.function})',
254-
method=request.method,
255-
# Using `http.route` prevents it from being scrubbed if it contains a word like 'secret'.
256-
# We don't use `http.method` because some dashboards do things like count spans with
257-
# both `http.method` and `http.route`.
258-
**{'http.route': request.scope['route'].path},
259-
**stack_info,
260-
_level='debug',
261-
):
262-
return await original_run_endpoint_function(dependant=dependant, values=values, **kwargs)
278+
original = original_run_endpoint_function(dependant=dependant, values=values, **kwargs)
279+
root_span = request.scope.get(LOGFIRE_SPAN_SCOPE_KEY)
280+
if not (root_span and root_span.is_recording()): # pragma: no cover
281+
# This should never happen because we only get to this function after solve_dependencies
282+
# passes the same check, just being paranoid.
283+
return await original
284+
285+
if self.extra_spans:
286+
callback = inspect.unwrap(dependant.call)
287+
code = getattr(callback, '__code__', None)
288+
stack_info: StackInfo = get_code_object_info(code) if code else {}
289+
extra_span = self.logfire_instance.span(
290+
'{method} {http.route} ({code.function})',
291+
method=request.method,
292+
# Using `http.route` prevents it from being scrubbed if it contains a word like 'secret'.
293+
# We don't use `http.method` because some dashboards do things like count spans with
294+
# both `http.method` and `http.route`.
295+
**{'http.route': request.scope['route'].path},
296+
**stack_info,
297+
_level='debug',
298+
)
299+
else:
300+
extra_span = NoopSpan()
301+
with extra_span, self.pseudo_span('endpoint_function', root_span):
302+
return await original
263303

264304

265305
def _default_request_attributes_mapper(

logfire/_internal/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,7 @@ def instrument_fastapi(
10351035
| None = None,
10361036
excluded_urls: str | Iterable[str] | None = None,
10371037
record_send_receive: bool = False,
1038+
extra_spans: bool = True,
10381039
**opentelemetry_kwargs: Any,
10391040
) -> AbstractContextManager[None]:
10401041
"""Instrument a FastAPI app so that spans and logs are automatically created for each request.
@@ -1069,6 +1070,7 @@ def instrument_fastapi(
10691070
These are disabled by default to reduce overhead and the number of spans created,
10701071
since many can be created for a single request, and they are not often useful.
10711072
If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI.
1073+
extra_spans: Whether to include the extra 'FastAPI arguments' and 'endpoint function' spans.
10721074
opentelemetry_kwargs: Additional keyword arguments to pass to the OpenTelemetry FastAPI instrumentation.
10731075
10741076
Returns:
@@ -1088,6 +1090,7 @@ def instrument_fastapi(
10881090
request_attributes_mapper=request_attributes_mapper,
10891091
excluded_urls=excluded_urls,
10901092
record_send_receive=record_send_receive,
1093+
extra_spans=extra_spans,
10911094
**opentelemetry_kwargs,
10921095
)
10931096

0 commit comments

Comments
 (0)