Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fastapi arguments attributes directly on the root otel span #509

Merged
merged 8 commits into from
Oct 24, 2024
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
21 changes: 8 additions & 13 deletions docs/integrations/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,12 @@ if __name__ == "__main__":
uvicorn.run(app)
```

Then visit http://localhost:8000/hello?name=world and check the logs.
Then visit [http://localhost:8000/hello?name=world](http://localhost:8000/hello?name=world) and check the logs.

## OpenTelemetry FastAPI Instrumentation
[`logfire.instrument_fastapi()`][logfire.Logfire.instrument_fastapi] accepts arbitrary additional keyword arguments
and passes them to the OpenTelemetry `FastAPIInstrumentor.instrument_app()` method. See [their documentation][opentelemetry-fastapi] for more details.

The third-party [OpenTelemetry FastAPI Instrumentation][opentelemetry-fastapi] package adds spans to every request with
detailed attributes about the HTTP request such as the full URL and the user agent. The start and end times let you see
how long it takes to process each request.

[`logfire.instrument_fastapi()`][logfire.Logfire.instrument_fastapi] applies this instrumentation by default.
You can disable it by passing `use_opentelemetry_instrumentation=False`.

[`logfire.instrument_fastapi()`][logfire.Logfire.instrument_fastapi] also accepts arbitrary additional keyword arguments
and passes them to the OpenTelemetry `FastAPIInstrumentor.instrument_app()` method. See their documentation for more details.

## Logfire instrumentation: logging endpoint arguments and validation errors
## Endpoint arguments and validation errors

[`logfire.instrument_fastapi()`][logfire.Logfire.instrument_fastapi] will emit a span for each request
called `FastAPI arguments` which shows how long it takes FastAPI to parse and validate the endpoint function
Expand Down Expand Up @@ -94,6 +85,10 @@ logfire.instrument_fastapi(app, request_attributes_mapper=request_attributes_map
The [`request_attributes_mapper`][logfire.Logfire.instrument_fastapi(request_attributes_mapper)] function mustn't mutate the
contents of `values` or `errors`, but it can safely replace them with new values.

!!! note
The attributes on the `FastAPI arguments` span are also set on the root span created by OpenTelemetry for easier querying.
The `values` and `error` attributes are under the names `fastapi.arguments.values` and `fastapi.arguments.errors` to avoid name collisions.

## Excluding URLs from instrumentation
<!-- note that this section is duplicated for different frameworks but with slightly different links -->

Expand Down
102 changes: 49 additions & 53 deletions logfire/_internal/integrations/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,27 @@
import inspect
from contextlib import contextmanager
from functools import lru_cache
from typing import Any, Awaitable, Callable, ContextManager, Iterable, cast
from typing import Any, Awaitable, Callable, ContextManager, Iterable
from weakref import WeakKeyDictionary

import fastapi.routing
from fastapi import BackgroundTasks, FastAPI
from fastapi.routing import APIRoute, APIWebSocketRoute, Mount
from fastapi.security import SecurityScopes
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import Span
from starlette.requests import Request
from starlette.responses import Response
from starlette.websockets import WebSocket

from ..main import Logfire
from ..main import Logfire, set_user_attributes_on_raw_span
from ..stack_info import StackInfo, get_code_object_info
from ..utils import maybe_capture_server_headers
from .asgi import tweak_asgi_spans_tracer_provider

try:
from opentelemetry.instrumentation.asgi import get_host_port_url_tuple # type: ignore
from opentelemetry.instrumentation.asgi import ServerRequestHook
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.util.http import get_excluded_urls, parse_excluded_urls
except ModuleNotFoundError:
raise RuntimeError(
'The `logfire.instrument_fastapi()` requires the `opentelemetry-instrumentation-fastapi` package.\n'
Expand Down Expand Up @@ -58,7 +58,6 @@ def instrument_fastapi(
dict[str, Any] | None,
]
| None = None,
use_opentelemetry_instrumentation: bool = True,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option was a bad idea on my part, clutters the docs and API, and doesn't really have a purpose any more now that OTEL kwargs are passed through.

excluded_urls: str | Iterable[str] | None = None,
record_send_receive: bool = False,
**opentelemetry_kwargs: Any,
Expand All @@ -72,18 +71,18 @@ def instrument_fastapi(
# FastAPIInstrumentor expects a comma-separated string, not a list.
excluded_urls = ','.join(excluded_urls)

if use_opentelemetry_instrumentation: # pragma: no branch
maybe_capture_server_headers(capture_headers)
opentelemetry_kwargs = {
'tracer_provider': tweak_asgi_spans_tracer_provider(logfire_instance, record_send_receive),
'meter_provider': logfire_instance.config.get_meter_provider(),
**opentelemetry_kwargs,
}
FastAPIInstrumentor.instrument_app( # type: ignore
app,
excluded_urls=excluded_urls,
**opentelemetry_kwargs,
)
maybe_capture_server_headers(capture_headers)
opentelemetry_kwargs = {
'tracer_provider': tweak_asgi_spans_tracer_provider(logfire_instance, record_send_receive),
'meter_provider': logfire_instance.config.get_meter_provider(),
**opentelemetry_kwargs,
}
FastAPIInstrumentor.instrument_app( # type: ignore
app,
excluded_urls=excluded_urls,
server_request_hook=_server_request_hook(opentelemetry_kwargs.pop('server_request_hook', None)),
**opentelemetry_kwargs,
)

registry = patch_fastapi()
if app in registry: # pragma: no cover
Expand All @@ -96,7 +95,6 @@ def instrument_fastapi(
registry[_app] = FastAPIInstrumentation(
logfire_instance,
request_attributes_mapper or _default_request_attributes_mapper,
excluded_urls,
)

@contextmanager
Expand All @@ -109,8 +107,7 @@ def uninstrument_context():
finally:
for _app in mounted_apps:
del registry[_app]
if use_opentelemetry_instrumentation: # pragma: no branch
FastAPIInstrumentor.uninstrument_app(_app)
FastAPIInstrumentor.uninstrument_app(_app)

return uninstrument_context()

Expand Down Expand Up @@ -160,29 +157,27 @@ def __init__(
],
dict[str, Any] | None,
],
excluded_urls: str | None,
):
self.logfire_instance = logfire_instance.with_settings(custom_scope_suffix='fastapi')
self.request_attributes_mapper = request_attributes_mapper

# These lines, as well as the `excluded_urls_list.url_disabled` call below, are copied from OTEL.
if excluded_urls is None:
self.excluded_urls_list = get_excluded_urls('FASTAPI')
else:
self.excluded_urls_list = parse_excluded_urls(excluded_urls) # pragma: no cover

async def solve_dependencies(self, request: Request | WebSocket, original: Awaitable[Any]) -> Any:
try:
url = cast(str, get_host_port_url_tuple(request.scope)[2])
excluded = self.excluded_urls_list.url_disabled(url)
except Exception: # pragma: no cover
excluded = False
self.logfire_instance.exception('Error checking if URL is excluded from instrumentation')

if excluded:
root_span = request.scope.get(LOGFIRE_SPAN_SCOPE_KEY)
if not root_span:
return await original # pragma: no cover

with self.logfire_instance.span('FastAPI arguments') as span:
if isinstance(request, Request): # pragma: no branch
span.set_attribute(SpanAttributes.HTTP_METHOD, request.method)
route: APIRoute | APIWebSocketRoute | None = request.scope.get('route')
if route: # pragma: no branch
root_span.set_attribute('fastapi.route.name', route.name)
span.set_attribute('fastapi.route.name', route.name)
span.set_attribute(SpanAttributes.HTTP_ROUTE, route.path)
if isinstance(route, APIRoute): # pragma: no branch
root_span.set_attribute('fastapi.route.operation_id', route.operation_id)
span.set_attribute('fastapi.route.operation_id', route.operation_id)

result: Any = await original

solved_values: dict[str, Any]
Expand Down Expand Up @@ -233,24 +228,11 @@ def solved_with_new_values(new_values: dict[str, Any]) -> Any:
if attributes.get('errors'):
span.set_level('error')

# Add a few basic attributes about the request, particularly so that the user can group logs by endpoint.
# Usually this will all be inside a span added by FastAPIInstrumentor with more detailed attributes.
# We only add these attributes after the request_attributes_mapper so that the user
# doesn't rely on what we add here - they can use `request` instead.
if isinstance(request, Request): # pragma: no branch
attributes[SpanAttributes.HTTP_METHOD] = request.method
route: APIRoute | APIWebSocketRoute | None = request.scope.get('route')
if route: # pragma: no branch
attributes.update(
{
SpanAttributes.HTTP_ROUTE: route.path,
'fastapi.route.name': route.name,
}
)
if isinstance(route, APIRoute): # pragma: no branch
attributes['fastapi.route.operation_id'] = route.operation_id

span.set_attributes(attributes)
for key in ('values', 'errors'):
if key in attributes: # pragma: no branch
attributes['fastapi.arguments.' + key] = attributes.pop(key)
set_user_attributes_on_raw_span(root_span, attributes)
except Exception as e: # pragma: no cover
span.record_exception(e)

Expand Down Expand Up @@ -289,3 +271,17 @@ def _default_request_attributes_mapper(

class _InstrumentedValues(dict): # type: ignore
request: Request


LOGFIRE_SPAN_SCOPE_KEY = 'logfire.span'


def _server_request_hook(user_hook: ServerRequestHook | None):
# Add the span to the request scope so that we can access it in `solve_dependencies`.
# Also call the user's hook if they passed one.
def hook(span: Span, scope: dict[str, Any]):
scope[LOGFIRE_SPAN_SCOPE_KEY] = span
if user_hook:
user_hook(span, scope)

return hook
26 changes: 19 additions & 7 deletions logfire/_internal/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import contextlib
import inspect
import json
import sys
import traceback
import warnings
Expand Down Expand Up @@ -866,13 +868,15 @@ def instrument_fastapi(
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]:
"""Instrument a FastAPI app so that spans and logs are automatically created for each request.

Uses the [OpenTelemetry FastAPI Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html)
under the hood, with some additional features.

Args:
app: The FastAPI app to instrument.
capture_headers: Set to `True` to capture all request and response headers.
Expand All @@ -895,11 +899,6 @@ def instrument_fastapi(
matches any of the regexes. This applies to both the Logfire and OpenTelemetry instrumentation.
If not provided, the environment variables
`OTEL_PYTHON_FASTAPI_EXCLUDED_URLS` and `OTEL_PYTHON_EXCLUDED_URLS` will be checked.
use_opentelemetry_instrumentation: If True (the default) then
[`FastAPIInstrumentor`][opentelemetry.instrumentation.fastapi.FastAPIInstrumentor]
will also instrument the app.

See [OpenTelemetry FastAPI Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html).
record_send_receive: Set to True to allow the OpenTelemetry ASGI to create send/receive spans.
These are disabled by default to reduce overhead and the number of spans created,
since many can be created for a single request, and they are not often useful.
Expand All @@ -922,7 +921,6 @@ def instrument_fastapi(
capture_headers=capture_headers,
request_attributes_mapper=request_attributes_mapper,
excluded_urls=excluded_urls,
use_opentelemetry_instrumentation=use_opentelemetry_instrumentation,
record_send_receive=record_send_receive,
**opentelemetry_kwargs,
)
Expand Down Expand Up @@ -1994,5 +1992,19 @@ def set_user_attribute(
return key, otel_value


def set_user_attributes_on_raw_span(span: Span, attributes: dict[str, Any]) -> None:
otlp_attributes = user_attributes(attributes)
if json_schema_properties := attributes_json_schema_properties(attributes): # pragma: no branch
existing_properties = JsonSchemaProperties({})
existing_json_schema_str = (span.attributes or {}).get(ATTRIBUTES_JSON_SCHEMA_KEY)
if existing_json_schema_str and isinstance(existing_json_schema_str, str):
with contextlib.suppress(json.JSONDecodeError):
existing_json_schema = json.loads(existing_json_schema_str)
existing_properties = existing_json_schema.get('properties', {})
existing_properties.update(json_schema_properties)
otlp_attributes[ATTRIBUTES_JSON_SCHEMA_KEY] = attributes_json_schema(existing_properties)
span.set_attributes(otlp_attributes)


P = ParamSpec('P')
R = TypeVar('R')
Loading