diff --git a/Makefile b/Makefile index 8a150f435..12c340f52 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ typecheck: .PHONY: test # Run the tests test: - uv run coverage run -m pytest + uv run --no-sync coverage run -m pytest .PHONY: generate-stubs # Generate stubs for logfire-api generate-stubs: diff --git a/docs/integrations/asgi.md b/docs/integrations/asgi.md index 13a1f311c..17e322e79 100644 --- a/docs/integrations/asgi.md +++ b/docs/integrations/asgi.md @@ -1,15 +1,13 @@ # ASGI -If the [ASGI][asgi] framework doesn't have a dedicated OpenTelemetry package, you can use the -[OpenTelemetry ASGI middleware][opentelemetry-asgi]. +If the [ASGI][asgi] web framework you're using doesn't have a dedicated integration, you can use the +[`logfire.instrument_asgi()`][logfire.Logfire.instrument_asgi] method to instrument it. ## Installation -You need to install the `opentelemetry-instrumentation-asgi` package: +Install `logfire` with the `asgi` extra: -```bash -pip install opentelemetry-instrumentation-asgi -``` +{{ install_logfire(extras=['asgi']) }} ## Usage @@ -17,7 +15,6 @@ Below we have a minimal example using [Uvicorn][uvicorn]. You can run it with `p ```py title="main.py" import logfire -from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware logfire.configure() @@ -34,7 +31,7 @@ async def app(scope, receive, send): ) await send({"type": "http.response.body", "body": b"Hello, world!"}) -app = OpenTelemetryMiddleware(app) +app = logfire.instrument_asgi(app) if __name__ == "__main__": import uvicorn @@ -42,7 +39,9 @@ if __name__ == "__main__": uvicorn.run(app) ``` -You can read more about the OpenTelemetry ASGI middleware [here][opentelemetry-asgi]. +The keyword arguments of [`logfire.instrument_asgi()`][logfire.Logfire.instrument_asgi] are passed to the +[`OpenTelemetryMiddleware`][opentelemetry.instrumentation.asgi.OpenTelemetryMiddleware] class +of the OpenTelemetry ASGI Instrumentation package. ## Excluding URLs from instrumentation @@ -50,7 +49,7 @@ You can read more about the OpenTelemetry ASGI middleware [here][opentelemetry-a - [Quick guide](use-cases/web-frameworks.md#excluding-urls-from-instrumentation) !!! note - `OpenTelemetryMiddleware` does accept an `excluded_urls` parameter, but does not support specifying said URLs via an environment variable, + `instrument_asgi` does accept an `excluded_urls` parameter, but does not support specifying said URLs via an environment variable, unlike other instrumentations. ## Capturing request and response headers @@ -60,5 +59,4 @@ You can read more about the OpenTelemetry ASGI middleware [here][opentelemetry-a - [OpenTelemetry Documentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html#capture-http-request-and-response-headers) [asgi]: https://asgi.readthedocs.io/en/latest/ -[opentelemetry-asgi]: https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html [uvicorn]: https://www.uvicorn.org/ diff --git a/docs/integrations/wsgi.md b/docs/integrations/wsgi.md index 8815bc3d5..1f664a83c 100644 --- a/docs/integrations/wsgi.md +++ b/docs/integrations/wsgi.md @@ -1,15 +1,13 @@ # WSGI -If the [WSGI][wsgi] framework doesn't have a dedicated OpenTelemetry package, you can use the -[OpenTelemetry WSGI middleware][opentelemetry-wsgi]. +If the [WSGI][wsgi] web framework you're using doesn't have a dedicated integration, you can use the +[`logfire.instrument_wsgi()`][logfire.Logfire.instrument_wsgi] method to instrument it. ## Installation -You need to install the [`opentelemetry-instrumentation-wsgi`][pypi-otel-wsgi] package: +Install `logfire` with the `wsgi` extra: -```bash -pip install opentelemetry-instrumentation-wsgi -``` +{{ install_logfire(extras=['wsgi']) }} ## Usage @@ -18,14 +16,16 @@ Below we have a minimal example using the standard library [`wsgiref`][wsgiref]. ```py title="main.py" from wsgiref.simple_server import make_server -from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware +import logfire + +logfire.configure() def app(env, start_response): start_response('200 OK', [('Content-Type','text/html')]) return [b"Hello World"] -app = OpenTelemetryMiddleware(app) +app = logfire.instrument_wsgi(app) with make_server("", 8000, app) as httpd: print("Serving on port 8000...") @@ -34,7 +34,10 @@ with make_server("", 8000, app) as httpd: httpd.serve_forever() ``` -You can read more about the OpenTelemetry WSGI middleware [here][opentelemetry-wsgi]. +The keyword arguments of [`logfire.instrument_wsgi()`][logfire.Logfire.instrument_wsgi] are passed to the +[`OpenTelemetryMiddleware`][opentelemetry.instrumentation.wsgi.OpenTelemetryMiddleware] class of +the OpenTelemetry WSGI Instrumentation package. + ## Capturing request and response headers @@ -43,6 +46,4 @@ You can read more about the OpenTelemetry WSGI middleware [here][opentelemetry-w - [OpenTelemetry Documentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/wsgi/wsgi.html#capture-http-request-and-response-headers) [wsgi]: https://wsgi.readthedocs.io/en/latest/ -[opentelemetry-wsgi]: https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/wsgi/wsgi.html -[pypi-otel-wsgi]: https://pypi.org/project/opentelemetry-instrumentation-wsgi/ [wsgiref]: https://docs.python.org/3/library/wsgiref.html diff --git a/logfire-api/logfire_api/__init__.py b/logfire-api/logfire_api/__init__.py index 71e4eb399..5bd85f043 100644 --- a/logfire-api/logfire_api/__init__.py +++ b/logfire-api/logfire_api/__init__.py @@ -94,6 +94,12 @@ def decorator(func): return decorator + def instrument_asgi(self, app, *args, **kwargs): + return app + + def instrument_wsgi(self, app, *args, **kwargs): + return app + def instrument_fastapi(self, *args, **kwargs) -> ContextManager[None]: return nullcontext() @@ -148,6 +154,8 @@ def shutdown(self, *args, **kwargs) -> None: ... log_slow_async_callbacks = DEFAULT_LOGFIRE_INSTANCE.log_slow_async_callbacks install_auto_tracing = DEFAULT_LOGFIRE_INSTANCE.install_auto_tracing instrument = DEFAULT_LOGFIRE_INSTANCE.instrument + instrument_asgi = DEFAULT_LOGFIRE_INSTANCE.instrument_asgi + instrument_wsgi = DEFAULT_LOGFIRE_INSTANCE.instrument_wsgi instrument_pydantic = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic instrument_fastapi = DEFAULT_LOGFIRE_INSTANCE.instrument_fastapi instrument_openai = DEFAULT_LOGFIRE_INSTANCE.instrument_openai diff --git a/logfire-api/logfire_api/__init__.pyi b/logfire-api/logfire_api/__init__.pyi index 32403b2a3..befbeba0a 100644 --- a/logfire-api/logfire_api/__init__.pyi +++ b/logfire-api/logfire_api/__init__.pyi @@ -12,7 +12,7 @@ from .version import VERSION as VERSION from logfire.sampling import SamplingOptions as SamplingOptions from typing import Any -__all__ = ['Logfire', 'LogfireSpan', 'LevelName', 'AdvancedOptions', 'ConsoleOptions', 'CodeSource', 'PydanticPlugin', 'configure', 'span', 'instrument', 'log', 'trace', 'debug', 'notice', 'info', 'warn', 'error', 'exception', 'fatal', 'force_flush', 'log_slow_async_callbacks', 'install_auto_tracing', 'instrument_pydantic', 'instrument_fastapi', 'instrument_openai', 'instrument_anthropic', 'instrument_asyncpg', 'instrument_httpx', 'instrument_celery', 'instrument_requests', 'instrument_psycopg', 'instrument_django', 'instrument_flask', 'instrument_starlette', 'instrument_aiohttp_client', 'instrument_sqlalchemy', 'instrument_redis', 'instrument_pymongo', 'instrument_mysql', 'instrument_system_metrics', 'AutoTraceModule', 'with_tags', 'with_settings', 'shutdown', 'load_spans_from_file', 'no_auto_trace', 'ScrubMatch', 'ScrubbingOptions', 'VERSION', 'suppress_instrumentation', 'StructlogProcessor', 'LogfireLoggingHandler', 'loguru_handler', 'SamplingOptions', 'MetricsOptions'] +__all__ = ['Logfire', 'LogfireSpan', 'LevelName', 'AdvancedOptions', 'ConsoleOptions', 'CodeSource', 'PydanticPlugin', 'configure', 'span', 'instrument', 'log', 'trace', 'debug', 'notice', 'info', 'warn', 'error', 'exception', 'fatal', 'force_flush', 'log_slow_async_callbacks', 'install_auto_tracing', 'instrument_asgi', 'instrument_wsgi', 'instrument_pydantic', 'instrument_fastapi', 'instrument_openai', 'instrument_anthropic', 'instrument_asyncpg', 'instrument_httpx', 'instrument_celery', 'instrument_requests', 'instrument_psycopg', 'instrument_django', 'instrument_flask', 'instrument_starlette', 'instrument_aiohttp_client', 'instrument_sqlalchemy', 'instrument_redis', 'instrument_pymongo', 'instrument_mysql', 'instrument_system_metrics', 'AutoTraceModule', 'with_tags', 'with_settings', 'shutdown', 'load_spans_from_file', 'no_auto_trace', 'ScrubMatch', 'ScrubbingOptions', 'VERSION', 'suppress_instrumentation', 'StructlogProcessor', 'LogfireLoggingHandler', 'loguru_handler', 'SamplingOptions', 'MetricsOptions'] DEFAULT_LOGFIRE_INSTANCE = Logfire() span = DEFAULT_LOGFIRE_INSTANCE.span @@ -21,6 +21,8 @@ force_flush = DEFAULT_LOGFIRE_INSTANCE.force_flush log_slow_async_callbacks = DEFAULT_LOGFIRE_INSTANCE.log_slow_async_callbacks install_auto_tracing = DEFAULT_LOGFIRE_INSTANCE.install_auto_tracing instrument_pydantic = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic +instrument_asgi = DEFAULT_LOGFIRE_INSTANCE.instrument_asgi +instrument_wsgi = DEFAULT_LOGFIRE_INSTANCE.instrument_wsgi instrument_fastapi = DEFAULT_LOGFIRE_INSTANCE.instrument_fastapi instrument_openai = DEFAULT_LOGFIRE_INSTANCE.instrument_openai instrument_anthropic = DEFAULT_LOGFIRE_INSTANCE.instrument_anthropic diff --git a/logfire-api/logfire_api/_internal/integrations/asgi.pyi b/logfire-api/logfire_api/_internal/integrations/asgi.pyi index e0cd64d50..3dc04e4ba 100644 --- a/logfire-api/logfire_api/_internal/integrations/asgi.pyi +++ b/logfire-api/logfire_api/_internal/integrations/asgi.pyi @@ -1,9 +1,28 @@ from dataclasses import dataclass from logfire import Logfire as Logfire -from logfire._internal.utils import is_asgi_send_receive_span_name as is_asgi_send_receive_span_name +from logfire._internal.utils import is_asgi_send_receive_span_name as is_asgi_send_receive_span_name, maybe_capture_server_headers as maybe_capture_server_headers from opentelemetry.context import Context from opentelemetry.trace import Span, Tracer, TracerProvider -from typing import Any +from typing import Any, Awaitable, Callable, Protocol, TypedDict +from typing_extensions import Unpack + +Scope = dict[str, Any] +Receive = Callable[[], Awaitable[dict[str, Any]]] +Send = Callable[[dict[str, Any]], Awaitable[None]] + +class ASGIApp(Protocol): + def __call__(self, scope: Scope, receive: Receive, send: Send) -> Awaitable[None]: ... +Hook = Callable[[Span, dict[str, Any]], None] + +class ASGIInstrumentKwargs(TypedDict, total=False): + excluded_urls: str | None + default_span_details: Callable[[Scope], tuple[str, dict[str, Any]]] + server_request_hook: Hook | None + client_request_hook: Hook | None + client_response_hook: Hook | None + http_capture_headers_server_request: list[str] | None + http_capture_headers_server_response: list[str] | None + http_capture_headers_sanitize_fields: list[str] | None def tweak_asgi_spans_tracer_provider(logfire_instance: Logfire, record_send_receive: bool) -> TracerProvider: """If record_send_receive is False, return a TracerProvider that skips spans for ASGI send and receive events.""" @@ -18,3 +37,9 @@ class TweakAsgiSpansTracer(Tracer): tracer: Tracer def start_span(self, name: str, context: Context | None = None, *args: Any, **kwargs: Any) -> Span: ... start_as_current_span = ... + +def instrument_asgi(logfire_instance: Logfire, app: ASGIApp, *, record_send_receive: bool = False, capture_headers: bool = False, **kwargs: Unpack[ASGIInstrumentKwargs]) -> ASGIApp: + """Instrument `app` so that spans are automatically created for each request. + + See the `Logfire.instrument_asgi` method for details. + """ diff --git a/logfire-api/logfire_api/_internal/integrations/wsgi.pyi b/logfire-api/logfire_api/_internal/integrations/wsgi.pyi new file mode 100644 index 000000000..8aa430386 --- /dev/null +++ b/logfire-api/logfire_api/_internal/integrations/wsgi.pyi @@ -0,0 +1,20 @@ +from logfire import Logfire as Logfire +from logfire._internal.utils import maybe_capture_server_headers as maybe_capture_server_headers +from opentelemetry.trace import Span +from typing import Callable, Protocol, TypedDict +from typing_extensions import Unpack +from wsgiref.types import WSGIApplication, WSGIEnvironment + +class ResponseHook(Protocol): + def __call__(self, span: Span, environ: WSGIEnvironment, status_code: int, response_headers: list[tuple[str, str]]) -> None: ... +RequestHook = Callable[[Span, WSGIEnvironment], None] + +class WSGIInstrumentKwargs(TypedDict, total=False): + request_hook: RequestHook | None + response_hook: ResponseHook | None + +def instrument_wsgi(logfire_instance: Logfire, app: WSGIApplication, *, capture_headers: bool = False, **kwargs: Unpack[WSGIInstrumentKwargs]) -> WSGIApplication: + """Instrument `app` so that spans are automatically created for each request. + + See the `Logfire.instrument_wsgi` method for details. + """ diff --git a/logfire-api/logfire_api/_internal/main.pyi b/logfire-api/logfire_api/_internal/main.pyi index 71c61fdf6..5e89dd946 100644 --- a/logfire-api/logfire_api/_internal/main.pyi +++ b/logfire-api/logfire_api/_internal/main.pyi @@ -9,6 +9,7 @@ from .config_params import PydanticPluginRecordValues as PydanticPluginRecordVal from .constants import ATTRIBUTES_JSON_SCHEMA_KEY as ATTRIBUTES_JSON_SCHEMA_KEY, 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_SAMPLE_RATE_KEY as ATTRIBUTES_SAMPLE_RATE_KEY, ATTRIBUTES_SPAN_TYPE_KEY as ATTRIBUTES_SPAN_TYPE_KEY, ATTRIBUTES_TAGS_KEY as ATTRIBUTES_TAGS_KEY, ATTRIBUTES_VALIDATION_ERROR_KEY as ATTRIBUTES_VALIDATION_ERROR_KEY, DISABLE_CONSOLE_KEY as DISABLE_CONSOLE_KEY, LEVEL_NUMBERS as LEVEL_NUMBERS, LevelName as LevelName, NULL_ARGS_KEY as NULL_ARGS_KEY, OTLP_MAX_INT_SIZE as OTLP_MAX_INT_SIZE, log_level_attributes as log_level_attributes from .formatter import logfire_format as logfire_format, logfire_format_with_magic as logfire_format_with_magic from .instrument import instrument as instrument +from .integrations.asgi import ASGIApp as ASGIApp, ASGIInstrumentKwargs as ASGIInstrumentKwargs from .integrations.asyncpg import AsyncPGInstrumentKwargs as AsyncPGInstrumentKwargs from .integrations.celery import CeleryInstrumentKwargs as CeleryInstrumentKwargs from .integrations.flask import FlaskInstrumentKwargs as FlaskInstrumentKwargs @@ -20,6 +21,7 @@ from .integrations.redis import RedisInstrumentKwargs as RedisInstrumentKwargs from .integrations.sqlalchemy import SQLAlchemyInstrumentKwargs as SQLAlchemyInstrumentKwargs from .integrations.starlette import StarletteInstrumentKwargs as StarletteInstrumentKwargs from .integrations.system_metrics import Base as SystemMetricsBase, Config as SystemMetricsConfig +from .integrations.wsgi import WSGIInstrumentKwargs as WSGIInstrumentKwargs from .json_encoder import logfire_json_dumps as logfire_json_dumps from .json_schema import JsonSchemaProperties as JsonSchemaProperties, attributes_json_schema as attributes_json_schema, attributes_json_schema_properties as attributes_json_schema_properties, create_json_schema as create_json_schema from .metrics import ProxyMeterProvider as ProxyMeterProvider @@ -38,6 +40,7 @@ from starlette.requests import Request as Request from starlette.websockets import WebSocket as WebSocket from typing import Any, Callable, ContextManager, Iterable, Literal, Sequence, TypeVar from typing_extensions import LiteralString, ParamSpec, Unpack +from wsgiref.types import WSGIApplication ExcInfo = SysExcInfo | BaseException | bool | None @@ -600,25 +603,69 @@ class Logfire: def instrument_flask(self, app: Flask, *, capture_headers: bool = False, **kwargs: Unpack[FlaskInstrumentKwargs]) -> None: """Instrument `app` so that spans are automatically created for each request. - Set `capture_headers` to `True` to capture all request and response headers. - Uses the [OpenTelemetry Flask Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/flask/flask.html) library, specifically `FlaskInstrumentor().instrument_app()`, to which it passes `**kwargs`. + + Args: + app: The Flask app to instrument. + capture_headers: Set to `True` to capture all request and response headers. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry Flask instrumentation. """ def instrument_starlette(self, app: Starlette, *, capture_headers: bool = False, record_send_receive: bool = False, **kwargs: Unpack[StarletteInstrumentKwargs]) -> None: """Instrument `app` so that spans are automatically created for each request. - Set `capture_headers` to `True` to capture all request and response headers. - - Set `record_send_receive` 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. - If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI. - Uses the [OpenTelemetry Starlette Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/starlette/starlette.html) library, specifically `StarletteInstrumentor.instrument_app()`, to which it passes `**kwargs`. + + Args: + app: The Starlette app to instrument. + capture_headers: Set to `True` to capture all request and response headers. + record_send_receive: Set `record_send_receive` 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. + If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry Starlette instrumentation. + """ + def instrument_asgi(self, app: ASGIApp, capture_headers: bool = False, record_send_receive: bool = False, **kwargs: Unpack[ASGIInstrumentKwargs]) -> ASGIApp: + """Instrument `app` so that spans are automatically created for each request. + + Uses the ASGI [`OpenTelemetryMiddleware`][opentelemetry.instrumentation.asgi.OpenTelemetryMiddleware] under + the hood, to which it passes `**kwargs`. + + Warning: + Instead of modifying the app in place, this method returns the instrumented ASGI application. + + Args: + app: The ASGI application to instrument. + capture_headers: Set to `True` to capture all request and response headers. + 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. + If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry ASGI middleware. + + Returns: + The instrumented ASGI application. + """ + def instrument_wsgi(self, app: WSGIApplication, capture_headers: bool = False, **kwargs: Unpack[WSGIInstrumentKwargs]) -> WSGIApplication: + """Instrument `app` so that spans are automatically created for each request. + + Uses the WSGI [`OpenTelemetryMiddleware`][opentelemetry.instrumentation.wsgi.OpenTelemetryMiddleware] under + the hood, to which it passes `**kwargs`. + + Warning: + Instead of modifying the app in place, this method returns the instrumented WSGI application. + + Args: + app: The WSGI application to instrument. + capture_headers: Set to `True` to capture all request and response headers. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry WSGI middleware. + + Returns: + The instrumented WSGI application. """ def instrument_aiohttp_client(self, **kwargs: Any) -> None: """Instrument the `aiohttp` module so that spans are automatically created for each client request. diff --git a/logfire/__init__.py b/logfire/__init__.py index f2e5906fd..f7902d29b 100644 --- a/logfire/__init__.py +++ b/logfire/__init__.py @@ -32,6 +32,8 @@ log_slow_async_callbacks = DEFAULT_LOGFIRE_INSTANCE.log_slow_async_callbacks install_auto_tracing = DEFAULT_LOGFIRE_INSTANCE.install_auto_tracing instrument_pydantic = DEFAULT_LOGFIRE_INSTANCE.instrument_pydantic +instrument_asgi = DEFAULT_LOGFIRE_INSTANCE.instrument_asgi +instrument_wsgi = DEFAULT_LOGFIRE_INSTANCE.instrument_wsgi instrument_fastapi = DEFAULT_LOGFIRE_INSTANCE.instrument_fastapi instrument_openai = DEFAULT_LOGFIRE_INSTANCE.instrument_openai instrument_anthropic = DEFAULT_LOGFIRE_INSTANCE.instrument_anthropic @@ -111,6 +113,8 @@ def loguru_handler() -> dict[str, Any]: 'force_flush', 'log_slow_async_callbacks', 'install_auto_tracing', + 'instrument_asgi', + 'instrument_wsgi', 'instrument_pydantic', 'instrument_fastapi', 'instrument_openai', diff --git a/logfire/_internal/integrations/asgi.py b/logfire/_internal/integrations/asgi.py index c03f0c53a..6752b89f7 100644 --- a/logfire/_internal/integrations/asgi.py +++ b/logfire/_internal/integrations/asgi.py @@ -4,15 +4,40 @@ from typing import TYPE_CHECKING, Any from opentelemetry.context import Context +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware from opentelemetry.sdk.trace import Tracer as SDKTracer from opentelemetry.trace import NonRecordingSpan, Span, Tracer, TracerProvider from opentelemetry.trace.propagation import get_current_span -from logfire._internal.utils import is_asgi_send_receive_span_name +from logfire._internal.utils import is_asgi_send_receive_span_name, maybe_capture_server_headers if TYPE_CHECKING: + from typing import Any, Awaitable, Callable, Protocol, TypedDict + + from opentelemetry.trace import Span + from typing_extensions import Unpack + from logfire import Logfire + Scope = dict[str, Any] + Receive = Callable[[], Awaitable[dict[str, Any]]] + Send = Callable[[dict[str, Any]], Awaitable[None]] + + class ASGIApp(Protocol): + def __call__(self, scope: Scope, receive: Receive, send: Send) -> Awaitable[None]: ... + + Hook = Callable[[Span, dict[str, Any]], None] + + class ASGIInstrumentKwargs(TypedDict, total=False): + excluded_urls: str | None + default_span_details: Callable[[Scope], tuple[str, dict[str, Any]]] + server_request_hook: Hook | None + client_request_hook: Hook | None + client_response_hook: Hook | None + http_capture_headers_server_request: list[str] | None + http_capture_headers_server_response: list[str] | None + http_capture_headers_sanitize_fields: list[str] | None + def tweak_asgi_spans_tracer_provider(logfire_instance: Logfire, record_send_receive: bool) -> TracerProvider: """If record_send_receive is False, return a TracerProvider that skips spans for ASGI send and receive events.""" @@ -48,3 +73,26 @@ def start_span(self, name: str, context: Context | None = None, *args: Any, **kw # This means that `with start_as_current_span(...):` # is roughly equivalent to `with use_span(start_span(...)):` start_as_current_span = SDKTracer.start_as_current_span + + +def instrument_asgi( + logfire_instance: Logfire, + app: ASGIApp, + *, + record_send_receive: bool = False, + capture_headers: bool = False, + **kwargs: Unpack[ASGIInstrumentKwargs], +) -> ASGIApp: + """Instrument `app` so that spans are automatically created for each request. + + See the `Logfire.instrument_asgi` method for details. + """ + maybe_capture_server_headers(capture_headers) + return OpenTelemetryMiddleware( + app, + **{ # type: ignore + 'tracer_provider': tweak_asgi_spans_tracer_provider(logfire_instance, record_send_receive), + 'meter_provider': logfire_instance.config.get_meter_provider(), + **kwargs, + }, + ) diff --git a/logfire/_internal/integrations/wsgi.py b/logfire/_internal/integrations/wsgi.py new file mode 100644 index 000000000..7d3ecd2bc --- /dev/null +++ b/logfire/_internal/integrations/wsgi.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware + +from logfire._internal.utils import maybe_capture_server_headers + +if TYPE_CHECKING: + from typing import Callable, Protocol, TypedDict + from wsgiref.types import WSGIApplication, WSGIEnvironment + + from opentelemetry.trace import Span + from typing_extensions import Unpack + + from logfire import Logfire + + class ResponseHook(Protocol): + def __call__( + self, span: Span, environ: WSGIEnvironment, status_code: int, response_headers: list[tuple[str, str]] + ) -> None: ... + + RequestHook = Callable[[Span, WSGIEnvironment], None] + + class WSGIInstrumentKwargs(TypedDict, total=False): + request_hook: RequestHook | None + """A callback called when a request is received by the server.""" + response_hook: ResponseHook | None + """A callback called when a response is sent by the server.""" + + +def instrument_wsgi( + logfire_instance: Logfire, + app: WSGIApplication, + *, + capture_headers: bool = False, + **kwargs: Unpack[WSGIInstrumentKwargs], +) -> WSGIApplication: + """Instrument `app` so that spans are automatically created for each request. + + See the `Logfire.instrument_wsgi` method for details. + """ + maybe_capture_server_headers(capture_headers) + return OpenTelemetryMiddleware( + app, + **{ + 'tracer_provider': logfire_instance.config.get_tracer_provider(), + 'meter_provider': logfire_instance.config.get_meter_provider(), + **kwargs, + }, + ) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index a68e77fc1..6f09dc112 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -55,6 +55,8 @@ from .utils import get_version, handle_internal_errors, log_internal_error, uniquify_sequence if TYPE_CHECKING: + from wsgiref.types import WSGIApplication + import anthropic import openai from django.http import HttpRequest, HttpResponse @@ -66,6 +68,7 @@ from starlette.websockets import WebSocket from typing_extensions import Unpack + from .integrations.asgi import ASGIApp, ASGIInstrumentKwargs from .integrations.asyncpg import AsyncPGInstrumentKwargs from .integrations.celery import CeleryInstrumentKwargs from .integrations.flask import FlaskInstrumentKwargs @@ -77,6 +80,7 @@ from .integrations.sqlalchemy import SQLAlchemyInstrumentKwargs from .integrations.starlette import StarletteInstrumentKwargs from .integrations.system_metrics import Base as SystemMetricsBase, Config as SystemMetricsConfig + from .integrations.wsgi import WSGIInstrumentKwargs from .utils import SysExcInfo # This is the type of the exc_info/_exc_info parameter of the log methods. @@ -87,6 +91,7 @@ # 3. The argument name exc_info is very suggestive of the sys function. ExcInfo = Union[SysExcInfo, BaseException, bool, None] + try: from pydantic import ValidationError except ImportError: # pragma: no cover @@ -897,7 +902,8 @@ 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. - record_send_receive: Set to True to allow the OpenTelemetry ASGI to create send/receive spans. + record_send_receive: Set to `True` to allow the OpenTelemetry ASGI middleware 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. If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI. @@ -1196,11 +1202,14 @@ def instrument_flask( ) -> None: """Instrument `app` so that spans are automatically created for each request. - Set `capture_headers` to `True` to capture all request and response headers. - Uses the [OpenTelemetry Flask Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/flask/flask.html) library, specifically `FlaskInstrumentor().instrument_app()`, to which it passes `**kwargs`. + + Args: + app: The Flask app to instrument. + capture_headers: Set to `True` to capture all request and response headers. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry Flask instrumentation. """ from .integrations.flask import instrument_flask @@ -1217,16 +1226,19 @@ def instrument_starlette( ) -> None: """Instrument `app` so that spans are automatically created for each request. - Set `capture_headers` to `True` to capture all request and response headers. - - Set `record_send_receive` 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. - If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI. - Uses the [OpenTelemetry Starlette Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/starlette/starlette.html) library, specifically `StarletteInstrumentor.instrument_app()`, to which it passes `**kwargs`. + + Args: + app: The Starlette app to instrument. + capture_headers: Set to `True` to capture all request and response headers. + record_send_receive: Set to `True` to allow the OpenTelemetry ASGI middleware 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. + If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry Starlette instrumentation. """ from .integrations.starlette import instrument_starlette @@ -1239,6 +1251,72 @@ def instrument_starlette( **kwargs, ) + def instrument_asgi( + self, + app: ASGIApp, + capture_headers: bool = False, + record_send_receive: bool = False, + **kwargs: Unpack[ASGIInstrumentKwargs], + ) -> ASGIApp: + """Instrument `app` so that spans are automatically created for each request. + + Uses the ASGI [`OpenTelemetryMiddleware`][opentelemetry.instrumentation.asgi.OpenTelemetryMiddleware] under + the hood, to which it passes `**kwargs`. + + Warning: + Instead of modifying the app in place, this method returns the instrumented ASGI application. + + Args: + app: The ASGI application to instrument. + capture_headers: Set to `True` to capture all request and response headers. + record_send_receive: Set to `True` to allow the OpenTelemetry ASGI middleware 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. + If enabled, they will be set to debug level, meaning they will usually still be hidden in the UI. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry ASGI middleware. + + Returns: + The instrumented ASGI application. + """ + from .integrations.asgi import instrument_asgi + + self._warn_if_not_initialized_for_instrumentation() + return instrument_asgi( + self, + app, + record_send_receive=record_send_receive, + capture_headers=capture_headers, + **kwargs, + ) + + def instrument_wsgi( + self, + app: WSGIApplication, + capture_headers: bool = False, + **kwargs: Unpack[WSGIInstrumentKwargs], + ) -> WSGIApplication: + """Instrument `app` so that spans are automatically created for each request. + + Uses the WSGI [`OpenTelemetryMiddleware`][opentelemetry.instrumentation.wsgi.OpenTelemetryMiddleware] under + the hood, to which it passes `**kwargs`. + + Warning: + Instead of modifying the app in place, this method returns the instrumented WSGI application. + + Args: + app: The WSGI application to instrument. + capture_headers: Set to `True` to capture all request and response headers. + **kwargs: Additional keyword arguments to pass to the OpenTelemetry WSGI middleware. + + Returns: + The instrumented WSGI application. + """ + from .integrations.wsgi import instrument_wsgi + + self._warn_if_not_initialized_for_instrumentation() + return instrument_wsgi(self, app, capture_headers=capture_headers, **kwargs) + def instrument_aiohttp_client(self, **kwargs: Any) -> None: """Instrument the `aiohttp` module so that spans are automatically created for each client request. diff --git a/pyproject.toml b/pyproject.toml index 106df4eaf..9de392b48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,8 @@ dependencies = [ [project.optional-dependencies] system-metrics = ["opentelemetry-instrumentation-system-metrics >= 0.42b0"] +asgi = ["opentelemetry-instrumentation-asgi >= 0.42b0"] +wsgi = ["opentelemetry-instrumentation-wsgi >= 0.42b0"] aiohttp = ["opentelemetry-instrumentation-aiohttp-client >= 0.42b0"] celery = ["opentelemetry-instrumentation-celery >= 0.42b0"] django = ["opentelemetry-instrumentation-django >= 0.42b0"] diff --git a/tests/otel_integrations/test_asgi.py b/tests/otel_integrations/test_asgi.py index e355eef2b..f59e53d0f 100644 --- a/tests/otel_integrations/test_asgi.py +++ b/tests/otel_integrations/test_asgi.py @@ -3,7 +3,6 @@ import contextlib from inline_snapshot import snapshot -from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware from opentelemetry.propagate import inject from starlette.applications import Starlette from starlette.middleware import Middleware @@ -24,7 +23,7 @@ def homepage(_: Request): logfire.info('inside request handler') return PlainTextResponse('middleware test') - app = Starlette(routes=[Route('/', homepage)], middleware=[Middleware(OpenTelemetryMiddleware)]) # type: ignore + app = Starlette(routes=[Route('/', homepage)], middleware=[Middleware(logfire.instrument_asgi)]) # type: ignore client = TestClient(app) with logfire.span('outside request handler'): @@ -53,46 +52,19 @@ def homepage(_: Request): 'code.function': 'homepage', }, }, - { - 'name': 'GET / http send response.start', - 'context': {'trace_id': 1, 'span_id': 6, 'is_remote': False}, - 'parent': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, - 'start_time': 4000000000, - 'end_time': 5000000000, - 'attributes': { - 'logfire.span_type': 'span', - 'logfire.msg': 'GET / http send response.start', - 'http.status_code': 200, - 'asgi.event.type': 'http.response.start', - 'logfire.level_num': 5, - 'http.response.status_code': 200, - }, - }, - { - 'name': 'GET / http send response.body', - 'context': {'trace_id': 1, 'span_id': 8, 'is_remote': False}, - 'parent': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, - 'start_time': 6000000000, - 'end_time': 7000000000, - 'attributes': { - 'logfire.span_type': 'span', - 'logfire.msg': 'GET / http send response.body', - 'asgi.event.type': 'http.response.body', - 'logfire.level_num': 5, - }, - }, { 'name': 'GET', 'context': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, 'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'start_time': 2000000000, - 'end_time': 8000000000, + 'end_time': 4000000000, 'attributes': { 'logfire.span_type': 'span', 'logfire.msg': 'GET /', 'http.scheme': 'http', 'url.scheme': 'http', 'http.host': 'testserver', + 'client.address': 'testserver', 'net.host.port': 80, 'server.port': 80, 'http.flavor': '1.1', @@ -106,7 +78,6 @@ def homepage(_: Request): 'http.user_agent': 'testclient', 'user_agent.original': 'testclient', 'net.peer.ip': 'testclient', - 'client.address': 'testserver', 'net.peer.port': 50000, 'client.port': 50000, 'http.status_code': 200, @@ -118,14 +89,14 @@ def homepage(_: Request): 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, - 'end_time': 9000000000, + 'end_time': 5000000000, 'attributes': { - 'code.lineno': 123, 'code.filepath': 'test_asgi.py', 'code.function': 'test_asgi_middleware', + 'code.lineno': 123, 'logfire.msg_template': 'outside request handler', - 'logfire.span_type': 'span', 'logfire.msg': 'outside request handler', + 'logfire.span_type': 'span', }, }, ] @@ -143,7 +114,7 @@ async def lifespan(app: ASGIApp): yield cleanup_complete = True - app = Starlette(lifespan=lifespan, middleware=[Middleware(OpenTelemetryMiddleware)]) # type: ignore + app = Starlette(lifespan=lifespan, middleware=[Middleware(logfire.instrument_asgi)]) # type: ignore with TestClient(app): assert startup_complete diff --git a/tests/otel_integrations/test_wsgi.py b/tests/otel_integrations/test_wsgi.py index 39d63f5aa..71f9a0783 100644 --- a/tests/otel_integrations/test_wsgi.py +++ b/tests/otel_integrations/test_wsgi.py @@ -2,7 +2,6 @@ from flask import Flask from inline_snapshot import snapshot -from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware from opentelemetry.propagate import inject from werkzeug.test import Client @@ -12,7 +11,7 @@ def test_wsgi_middleware(exporter: TestExporter) -> None: app = Flask(__name__) - app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) + app.wsgi_app = logfire.instrument_wsgi(app.wsgi_app) # type: ignore @app.route('/') def homepage(): # type: ignore diff --git a/tests/test_logfire_api.py b/tests/test_logfire_api.py index cbe9aae27..a9ee6bdbb 100644 --- a/tests/test_logfire_api.py +++ b/tests/test_logfire_api.py @@ -127,6 +127,14 @@ def func() -> None: ... func() logfire__all__.remove('instrument') + assert hasattr(logfire_api, 'instrument_asgi'), 'instrument_asgi' + assert getattr(logfire_api, 'instrument_asgi')(app=MagicMock()) is not None + logfire__all__.remove('instrument_asgi') + + assert hasattr(logfire_api, 'instrument_wsgi'), 'instrument_wsgi' + assert getattr(logfire_api, 'instrument_wsgi')(app=MagicMock()) is not None + logfire__all__.remove('instrument_wsgi') + for member in [m for m in ('instrument_flask', 'instrument_fastapi', 'instrument_starlette')]: assert hasattr(logfire_api, member), member getattr(logfire_api, member)(app=MagicMock()) diff --git a/uv.lock b/uv.lock index 335dc541e..1182c9d55 100644 --- a/uv.lock +++ b/uv.lock @@ -1420,6 +1420,9 @@ dependencies = [ aiohttp = [ { name = "opentelemetry-instrumentation-aiohttp-client" }, ] +asgi = [ + { name = "opentelemetry-instrumentation-asgi" }, +] asyncpg = [ { name = "opentelemetry-instrumentation-asyncpg" }, ] @@ -1467,6 +1470,9 @@ starlette = [ system-metrics = [ { name = "opentelemetry-instrumentation-system-metrics" }, ] +wsgi = [ + { name = "opentelemetry-instrumentation-wsgi" }, +] [package.dev-dependencies] dev = [ @@ -1549,6 +1555,7 @@ requires-dist = [ { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.21.0" }, { name = "opentelemetry-instrumentation", specifier = ">=0.41b0" }, { name = "opentelemetry-instrumentation-aiohttp-client", marker = "extra == 'aiohttp'", specifier = ">=0.42b0" }, + { name = "opentelemetry-instrumentation-asgi", marker = "extra == 'asgi'", specifier = ">=0.42b0" }, { name = "opentelemetry-instrumentation-asyncpg", marker = "extra == 'asyncpg'", specifier = ">=0.42b0" }, { name = "opentelemetry-instrumentation-celery", marker = "extra == 'celery'", specifier = ">=0.42b0" }, { name = "opentelemetry-instrumentation-django", marker = "extra == 'django'", specifier = ">=0.42b0" }, @@ -1564,6 +1571,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-sqlalchemy", marker = "extra == 'sqlalchemy'", specifier = ">=0.42b0" }, { name = "opentelemetry-instrumentation-starlette", marker = "extra == 'starlette'", specifier = ">=0.42b0" }, { name = "opentelemetry-instrumentation-system-metrics", marker = "extra == 'system-metrics'", specifier = ">=0.42b0" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "extra == 'wsgi'", specifier = ">=0.42b0" }, { name = "opentelemetry-sdk", specifier = ">=1.21.0" }, { name = "packaging", marker = "extra == 'psycopg'" }, { name = "packaging", marker = "extra == 'psycopg2'" },