From 37b6f0975ae041eb71a79806e3685df7452dccb8 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 19 Jul 2024 16:33:59 +0200 Subject: [PATCH 01/10] Add ASGI & WSGI instrument methods --- docs/integrations/asgi.md | 16 +++++------ docs/integrations/wsgi.md | 19 ++++++------- logfire/__init__.py | 2 ++ logfire/_internal/integrations/asgi.py | 38 ++++++++++++++++++++++++++ logfire/_internal/integrations/wsgi.py | 33 ++++++++++++++++++++++ logfire/_internal/main.py | 29 ++++++++++++++++++++ pyproject.toml | 2 ++ tests/otel_integrations/test_asgi.py | 5 ++-- tests/otel_integrations/test_wsgi.py | 3 +- 9 files changed, 123 insertions(+), 24 deletions(-) create mode 100644 logfire/_internal/integrations/wsgi.py diff --git a/docs/integrations/asgi.md b/docs/integrations/asgi.md index 13a1f311c..30217c9c8 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]. +[`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 @@ -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..38daee970 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]. +[`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,14 @@ 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 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 +32,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 +44,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/__init__.py b/logfire/__init__.py index f2e5906fd..013fe33cc 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 diff --git a/logfire/_internal/integrations/asgi.py b/logfire/_internal/integrations/asgi.py index c03f0c53a..88aeef2f1 100644 --- a/logfire/_internal/integrations/asgi.py +++ b/logfire/_internal/integrations/asgi.py @@ -48,3 +48,41 @@ 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 + + +from typing import TYPE_CHECKING + +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware + +if TYPE_CHECKING: + from typing import Any, Awaitable, Callable, Protocol, TypedDict + + from opentelemetry.trace import Span + 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 instrument_asgi(app: ASGIApp, **kwargs: Unpack[ASGIInstrumentKwargs]) -> ASGIApp: + """Instrument `app` so that spans are automatically created for each request. + + See the `Logfire.instrument_asgi` method for details. + """ + return OpenTelemetryMiddleware(app, **kwargs) diff --git a/logfire/_internal/integrations/wsgi.py b/logfire/_internal/integrations/wsgi.py new file mode 100644 index 000000000..4433bc6cd --- /dev/null +++ b/logfire/_internal/integrations/wsgi.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from wsgiref.types import WSGIApplication, WSGIEnvironment + +from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware + +if TYPE_CHECKING: + from typing import Callable, Protocol, TypedDict + + from opentelemetry.trace import Span + from typing_extensions import Unpack + + 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(app: WSGIApplication, **kwargs: Unpack[WSGIInstrumentKwargs]) -> WSGIApplication: + """Instrument `app` so that spans are automatically created for each request. + + See the `Logfire.instrument_wsgi` method for details. + """ + return OpenTelemetryMiddleware(app, **kwargs) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index a68e77fc1..bf1d01555 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 @@ -1239,6 +1244,30 @@ def instrument_starlette( **kwargs, ) + def instrument_asgi(self, app: ASGIApp, **kwargs: Unpack[ASGIInstrumentKwargs]) -> ASGIApp: + """Instrument `app` so that spans are automatically created for each request. + + Uses the + [OpenTelemetry ASGI Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html) + library, specifically `ASGIInstrumentor().instrument_app()`, to which it passes `**kwargs`. + """ + from .integrations.asgi import instrument_asgi + + self._warn_if_not_initialized_for_instrumentation() + return instrument_asgi(app, **kwargs) + + def instrument_wsgi(self, app: WSGIApplication, **kwargs: Unpack[WSGIInstrumentKwargs]) -> WSGIApplication: + """Instrument `app` so that spans are automatically created for each request. + + Uses the + [OpenTelemetry WSGI Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/wsgi/wsgi.html) + library, specifically `WSGIInstrumentor().instrument_app()`, to which it passes `**kwargs`. + """ + from .integrations.wsgi import instrument_wsgi + + self._warn_if_not_initialized_for_instrumentation() + return instrument_wsgi(app, **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..0bbf8cfa2 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'): @@ -143,7 +142,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 From 54446ebdba4ab228bae8cad442327cc78f9f5b4a Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 29 Oct 2024 16:17:16 +0100 Subject: [PATCH 02/10] update asgi wsgi --- Makefile | 2 +- logfire-api/logfire_api/__init__.py | 8 +++ logfire/__init__.py | 2 + logfire/_internal/integrations/asgi.py | 76 +++++++++++++++----------- logfire/_internal/integrations/wsgi.py | 22 +++++++- logfire/_internal/main.py | 4 +- tests/otel_integrations/test_asgi.py | 38 ++----------- tests/test_logfire_api.py | 8 +++ uv.lock | 8 +++ 9 files changed, 97 insertions(+), 71 deletions(-) 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/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/__init__.py b/logfire/__init__.py index 013fe33cc..f7902d29b 100644 --- a/logfire/__init__.py +++ b/logfire/__init__.py @@ -113,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 88aeef2f1..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.""" @@ -50,39 +75,24 @@ def start_span(self, name: str, context: Context | None = None, *args: Any, **kw start_as_current_span = SDKTracer.start_as_current_span -from typing import TYPE_CHECKING - -from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware - -if TYPE_CHECKING: - from typing import Any, Awaitable, Callable, Protocol, TypedDict - - from opentelemetry.trace import Span - 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 instrument_asgi(app: ASGIApp, **kwargs: Unpack[ASGIInstrumentKwargs]) -> ASGIApp: +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. """ - return OpenTelemetryMiddleware(app, **kwargs) + 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 index 4433bc6cd..bb625d008 100644 --- a/logfire/_internal/integrations/wsgi.py +++ b/logfire/_internal/integrations/wsgi.py @@ -5,12 +5,16 @@ 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 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]] @@ -25,9 +29,23 @@ class WSGIInstrumentKwargs(TypedDict, total=False): """A callback called when a response is sent by the server.""" -def instrument_wsgi(app: WSGIApplication, **kwargs: Unpack[WSGIInstrumentKwargs]) -> WSGIApplication: +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. """ - return OpenTelemetryMiddleware(app, **kwargs) + 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 bf1d01555..346124720 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -1254,7 +1254,7 @@ def instrument_asgi(self, app: ASGIApp, **kwargs: Unpack[ASGIInstrumentKwargs]) from .integrations.asgi import instrument_asgi self._warn_if_not_initialized_for_instrumentation() - return instrument_asgi(app, **kwargs) + return instrument_asgi(self, app, **kwargs) def instrument_wsgi(self, app: WSGIApplication, **kwargs: Unpack[WSGIInstrumentKwargs]) -> WSGIApplication: """Instrument `app` so that spans are automatically created for each request. @@ -1266,7 +1266,7 @@ def instrument_wsgi(self, app: WSGIApplication, **kwargs: Unpack[WSGIInstrumentK from .integrations.wsgi import instrument_wsgi self._warn_if_not_initialized_for_instrumentation() - return instrument_wsgi(app, **kwargs) + return instrument_wsgi(self, app, **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/tests/otel_integrations/test_asgi.py b/tests/otel_integrations/test_asgi.py index 0bbf8cfa2..f59e53d0f 100644 --- a/tests/otel_integrations/test_asgi.py +++ b/tests/otel_integrations/test_asgi.py @@ -52,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', @@ -105,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, @@ -117,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', }, }, ] 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'" }, From ac0c2ca0d48a23224578f96d0fde9c2a55cb6ca0 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Tue, 29 Oct 2024 16:24:19 +0100 Subject: [PATCH 03/10] move types to type checking --- logfire/_internal/integrations/wsgi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logfire/_internal/integrations/wsgi.py b/logfire/_internal/integrations/wsgi.py index bb625d008..7d3ecd2bc 100644 --- a/logfire/_internal/integrations/wsgi.py +++ b/logfire/_internal/integrations/wsgi.py @@ -1,7 +1,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from wsgiref.types import WSGIApplication, WSGIEnvironment from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware @@ -9,6 +8,7 @@ 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 5b9023d854c24dfa4326281044ef3477e77cf22f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 30 Oct 2024 11:24:19 +0100 Subject: [PATCH 04/10] Improve documentation --- docs/integrations/wsgi.md | 2 + logfire/_internal/main.py | 85 ++++++++++++++++++++++++++++++--------- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/docs/integrations/wsgi.md b/docs/integrations/wsgi.md index 38daee970..dd2c9f76a 100644 --- a/docs/integrations/wsgi.md +++ b/docs/integrations/wsgi.md @@ -19,6 +19,8 @@ from wsgiref.simple_server import make_server import logfire +logfire.configure() + def app(env, start_response): start_response('200 OK', [('Content-Type','text/html')]) return [b"Hello World"] diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 346124720..2b75c4462 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -1201,11 +1201,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 @@ -1222,16 +1225,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 `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. """ from .integrations.starlette import instrument_starlette @@ -1244,29 +1250,70 @@ def instrument_starlette( **kwargs, ) - def instrument_asgi(self, app: ASGIApp, **kwargs: Unpack[ASGIInstrumentKwargs]) -> ASGIApp: + 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 - [OpenTelemetry ASGI Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/asgi/asgi.html) - library, specifically `ASGIInstrumentor().instrument_app()`, to which it passes `**kwargs`. + 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. """ from .integrations.asgi import instrument_asgi self._warn_if_not_initialized_for_instrumentation() - return instrument_asgi(self, app, **kwargs) + return instrument_asgi( + self, + app, + record_send_receive=record_send_receive, + capture_headers=capture_headers, + **kwargs, + ) - def instrument_wsgi(self, app: WSGIApplication, **kwargs: Unpack[WSGIInstrumentKwargs]) -> WSGIApplication: + 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 - [OpenTelemetry WSGI Instrumentation](https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/wsgi/wsgi.html) - library, specifically `WSGIInstrumentor().instrument_app()`, to which it passes `**kwargs`. + 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, **kwargs) + 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. From e3e8ae6c2214fcd9b9e9e94c8b8d516c5ac163cf Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 30 Oct 2024 11:27:33 +0100 Subject: [PATCH 05/10] Add stubs --- logfire-api/logfire_api/__init__.pyi | 4 +- .../_internal/integrations/asgi.pyi | 29 ++++++++- .../_internal/integrations/wsgi.pyi | 20 ++++++ logfire-api/logfire_api/_internal/main.pyi | 65 ++++++++++++++++--- 4 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 logfire-api/logfire_api/_internal/integrations/wsgi.pyi 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. From 7e7541d1d5f56b453fda0f05035abd77156781c4 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 30 Oct 2024 11:38:29 +0100 Subject: [PATCH 06/10] Update docs/integrations/asgi.md Co-authored-by: Alex Hall --- docs/integrations/asgi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations/asgi.md b/docs/integrations/asgi.md index 30217c9c8..c7952f4a3 100644 --- a/docs/integrations/asgi.md +++ b/docs/integrations/asgi.md @@ -1,6 +1,6 @@ # ASGI -If the [ASGI][asgi] framework doesn't have a dedicated OpenTelemetry package, you can use the +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 From 3caca292e0e4c783b243e5b10417d40c38d83c67 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 30 Oct 2024 11:46:30 +0100 Subject: [PATCH 07/10] Update logfire/_internal/main.py Co-authored-by: Alex Hall --- logfire/_internal/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 2b75c4462..793dad9fc 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -1232,7 +1232,7 @@ def instrument_starlette( 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. + record_send_receive: Set `record_send_receive` 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. From ebe9af83274bb8720be2c5f617acbf27a022d2fe Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 30 Oct 2024 11:48:41 +0100 Subject: [PATCH 08/10] add middleware --- logfire/_internal/main.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 793dad9fc..6f09dc112 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -902,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. @@ -1232,7 +1233,7 @@ def instrument_starlette( 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 middleware 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. @@ -1268,7 +1269,8 @@ def instrument_asgi( 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. + 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. From 229ccdec9ed883deafe6043d7ffe98c0f01c816d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 30 Oct 2024 11:49:44 +0100 Subject: [PATCH 09/10] match wsgi note to asgi --- docs/integrations/wsgi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations/wsgi.md b/docs/integrations/wsgi.md index dd2c9f76a..1f664a83c 100644 --- a/docs/integrations/wsgi.md +++ b/docs/integrations/wsgi.md @@ -1,6 +1,6 @@ # WSGI -If the [WSGI][wsgi] framework doesn't have a dedicated OpenTelemetry package, you can use the +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 From 07d613631bf554f4d2d668039dea0cb0ee0d9545 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 30 Oct 2024 11:50:38 +0100 Subject: [PATCH 10/10] fix note --- docs/integrations/asgi.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations/asgi.md b/docs/integrations/asgi.md index c7952f4a3..17e322e79 100644 --- a/docs/integrations/asgi.md +++ b/docs/integrations/asgi.md @@ -49,7 +49,7 @@ of the OpenTelemetry ASGI Instrumentation package. - [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