Files
fastapi/tests/test_telemetry/test_runtime.py
T

649 lines
22 KiB
Python

import asyncio
import logging
import threading
from contextlib import asynccontextmanager
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from fastapi.exceptions import FastAPIError
from fastapi.telemetry import _runtime as runtime
from fastapi.testclient import TestClient
from ._otlp import otlp_collector
from ._subprocess import run_in_subprocess
def test_otlp_collector_does_not_resolve_hostname(monkeypatch):
# Reverse DNS during HTTPServer binding caused macOS CI timeouts.
lookup = Mock()
monkeypatch.setattr("socket.getfqdn", lookup)
with otlp_collector():
pass
lookup.assert_not_called()
@pytest.mark.parametrize("base_path", ["", "/collector/"])
@run_in_subprocess
def test_real_otlp_export_and_repeated_lifespans(base_path):
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.telemetry import _runtime
from fastapi.testclient import TestClient
from opentelemetry import _logs, trace
from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
ExportLogsServiceRequest,
)
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
ExportMetricsServiceRequest,
)
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
ExportTraceServiceRequest,
)
from opentelemetry.proto.trace.v1.trace_pb2 import Span
with otlp_collector() as (base, received):
prefix = base_path.rstrip("/")
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = base + base_path
os.environ["OTEL_SERVICE_NAME"] = "native-test"
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = "x-test=value"
os.environ["OTEL_RESOURCE_ATTRIBUTES"] = "test.resource=example"
os.environ["OTEL_BSP_SCHEDULE_DELAY"] = "60000"
os.environ["OTEL_BLRP_SCHEDULE_DELAY"] = "60000"
os.environ["OTEL_METRIC_EXPORT_INTERVAL"] = "60000"
seen = []
@asynccontextmanager
async def lifespan(app):
seen.append(trace.get_tracer_provider())
yield
app = FastAPI(lifespan=lifespan)
@app.get("/items/{value}")
def endpoint(value: int):
_logs.get_logger("test").emit(body="request processed")
return value
try:
for _ in range(2):
with TestClient(app) as client:
assert client.get("/items/3").json() == 3
assert len(_runtime._owned) == 3
assert seen[0] is seen[1]
assert all(headers.get("x-test") == "value" for _, _, headers in received)
traces = [
ExportTraceServiceRequest.FromString(body)
for path, body, _ in received
if path == prefix + "/v1/traces"
]
spans = [
span
for request in traces
for resource in request.resource_spans
for scope in resource.scope_spans
for span in scope.spans
]
assert len(spans) == 8
servers = [span for span in spans if span.kind == Span.SPAN_KIND_SERVER]
assert len(servers) == 2
assert all(span.name == "GET /items/{value}" for span in servers)
metric_requests = [
ExportMetricsServiceRequest.FromString(body)
for path, body, _ in received
if path == prefix + "/v1/metrics"
]
assert metric_requests
resources = metric_requests[-1].resource_metrics
resource_attributes = {
a.key: a.value.string_value for a in resources[0].resource.attributes
}
assert resource_attributes["service.name"] == "native-test"
assert resource_attributes["test.resource"] == "example"
histograms = [
metric.histogram
for resource in resources
for scope in resource.scope_metrics
for metric in scope.metrics
if metric.name == "http.server.request.duration"
]
assert histograms[0].data_points[0].count == 2
log_requests = [
ExportLogsServiceRequest.FromString(body)
for path, body, _ in received
if path == prefix + "/v1/logs"
]
records = [
record
for request in log_requests
for resource in request.resource_logs
for scope in resource.scope_logs
for record in scope.log_records
]
assert len(records) == 2
assert all(
record.body.string_value == "request processed" for record in records
)
finally:
_runtime._shutdown()
@pytest.mark.parametrize(
"env",
[
{
"OTEL_TRACES_EXPORTER": "",
"OTEL_METRICS_EXPORTER": "",
"OTEL_LOGS_EXPORTER": "",
"OTEL_EXPORTER_OTLP_PROTOCOL": "",
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "",
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL": "",
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": "",
},
{
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "",
},
],
)
def test_empty_environment_uses_defaults(monkeypatch, env):
monkeypatch.setenv(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318/collector/"
)
for name, value in env.items():
monkeypatch.setenv(name, value)
for signal in ("TRACES", "METRICS", "LOGS"):
assert runtime._export_endpoint(signal) == (
f"http://127.0.0.1:4318/collector/v1/{signal.lower()}"
)
@pytest.mark.parametrize("protocol", ["http/protobuf", "grpc"])
def test_empty_signal_protocol_uses_general_protocol(monkeypatch, protocol):
monkeypatch.setenv(
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://127.0.0.1:4318/v1/traces"
)
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", protocol)
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "")
if protocol == "http/protobuf":
assert runtime._export_endpoint("TRACES") == "http://127.0.0.1:4318/v1/traces"
else:
with pytest.raises(FastAPIError, match="http/protobuf"):
runtime._export_endpoint("TRACES")
@pytest.mark.parametrize("signal", ["TRACES", "METRICS", "LOGS"])
def test_signal_endpoint_overrides_general_endpoint(monkeypatch, signal):
endpoint = "http://127.0.0.1:4318/custom/"
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "invalid")
monkeypatch.setenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT", endpoint)
assert runtime._export_endpoint(signal) == endpoint
@pytest.mark.parametrize(
"env,message",
[
({"OTEL_EXPORTER_OTLP_ENDPOINT": "not-a-url"}, "absolute HTTP"),
(
{
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
},
"http/protobuf",
),
(
{
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1",
"OTEL_TRACES_EXPORTER": "console",
},
"otlp or none",
),
],
)
def test_invalid_configuration_warns_without_disabling_providers(
monkeypatch, caplog, telemetry, env, message
):
for name, value in env.items():
monkeypatch.setenv(name, value)
config, exporter, _ = telemetry
events = []
@asynccontextmanager
async def lifespan(app):
events.append("startup")
yield
events.append("shutdown")
app = FastAPI(telemetry=config, lifespan=lifespan)
@app.get("/health")
async def health():
return {"status": "ok"}
with TestClient(app) as client:
assert client.get("/health").json() == {"status": "ok"}
assert events == ["startup", "shutdown"]
assert exporter.get_finished_spans()
assert not runtime._owned
assert len(caplog.records) == 1
assert caplog.records[0].levelno == logging.WARNING
assert message in caplog.messages[0]
@pytest.mark.parametrize("stage", ["startup", "shutdown"])
def test_application_lifespan_errors_still_propagate(monkeypatch, stage):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "not-a-url")
@asynccontextmanager
async def lifespan(app):
if stage == "startup":
raise RuntimeError("application startup failed")
yield
raise RuntimeError("application shutdown failed")
with pytest.raises(RuntimeError, match=f"application {stage} failed"):
with TestClient(FastAPI(lifespan=lifespan)) as client:
assert client.get("/").status_code == 404
@pytest.mark.parametrize(
"env",
[
{},
{"OTEL_SERVICE_NAME": "no-endpoint"},
{"OTEL_TRACES_EXPORTER": "otlp", "OTEL_METRICS_EXPORTER": "otlp"},
{
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
"OTEL_LOGS_EXPORTER": "none",
},
{
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1",
"OTEL_SDK_DISABLED": "true",
},
],
)
@run_in_subprocess
def test_no_implicit_export(env):
import os
os.environ.update(env)
from fastapi import FastAPI
from fastapi.telemetry import _runtime
from fastapi.testclient import TestClient
with TestClient(FastAPI()) as client:
assert client.get("/").status_code == 404
assert _runtime._owned == []
@run_in_subprocess
def test_missing_sdk_warns_without_preventing_startup():
import os
os.environ.update(
{"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "http://127.0.0.1:1/metrics"}
)
import sys
from importlib.abc import MetaPathFinder
class BlockSDK(MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.startswith(("opentelemetry.sdk", "opentelemetry.exporter")):
raise ImportError("SDK absent")
sys.meta_path.insert(0, BlockSDK())
from unittest import TestCase
from fastapi import FastAPI
from fastapi.testclient import TestClient
with TestCase().assertLogs("fastapi", level="WARNING") as logs:
with TestClient(FastAPI()) as client:
assert client.get("/").status_code == 404
assert len(logs.records) == 1
assert logs.records[0].levelno == logging.WARNING
assert "fastapi[opentelemetry]" in logs.output[0]
assert not runtime._owned
@run_in_subprocess
def test_external_globals_are_unchanged_when_auto_configuration_is_disabled():
import os
os.environ.update({"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:1"})
from fastapi import FastAPI
from fastapi.telemetry import _runtime
from fastapi.testclient import TestClient
from opentelemetry import _logs, metrics, trace
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
exporter = InMemorySpanExporter()
tp = TracerProvider()
tp.add_span_processor(SimpleSpanProcessor(exporter))
mp = MeterProvider()
lp = LoggerProvider()
_logs.set_logger_provider(lp)
trace.set_tracer_provider(tp)
metrics.set_meter_provider(mp)
for _ in range(2):
with TestClient(FastAPI(telemetry={"auto_configure": False})) as client:
client.get("/")
assert trace.get_tracer_provider() is tp
assert metrics.get_meter_provider() is mp
assert _logs.get_logger_provider() is lp
assert not _runtime._owned
assert len(exporter.get_finished_spans()) == 2
def test_auto_configuration_opt_out(monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "invalid")
with TestClient(FastAPI(telemetry={"auto_configure": False})) as client:
assert client.get("/").status_code == 404
def test_owned_flush_failure_does_not_break_shutdown(monkeypatch, caplog):
calls = []
class FailingProvider:
def force_flush(self):
raise RuntimeError("flush failed")
def shutdown(self):
raise RuntimeError("shutdown failed")
class Provider:
def force_flush(self):
calls.append("flush")
def shutdown(self):
calls.append("shutdown")
monkeypatch.setattr(runtime, "_owned", [FailingProvider(), Provider()])
with TestClient(FastAPI()) as client:
assert client.get("/").status_code == 404
runtime._shutdown()
runtime._shutdown()
assert calls == ["flush", "shutdown"]
assert caplog.text.count("FastAPI telemetry cleanup failed") == 2
@pytest.mark.parametrize(
"message_type",
[
"lifespan.startup.failed",
"lifespan.shutdown.failed",
"lifespan.shutdown.complete",
],
)
def test_lifespan_flush_runs_off_event_loop(monkeypatch, message_type):
calls = []
flush_threads = []
event_loop_thread = threading.get_ident()
class Provider:
def force_flush(self):
flush_threads.append(threading.get_ident())
calls.append("flush")
def shutdown(self):
calls.append("shutdown")
monkeypatch.setattr(runtime, "_owned", [Provider()])
async def app(scope, receive, send):
assert await receive() == {"type": "lifespan.startup"}
await send({"type": message_type})
async def receive():
return {"type": "lifespan.startup"}
async def send(message):
calls.append(message["type"])
asyncio.run(
runtime.lifespan(
config=FastAPI()._telemetry, app=app, scope={}, receive=receive, send=send
)
)
assert calls == ["flush", message_type]
assert len(flush_threads) == 1
assert flush_threads[0] != event_loop_thread
runtime._shutdown()
runtime._shutdown()
assert calls == ["flush", message_type, "shutdown"]
def test_registration_provider_prefers_public_metric_reader(monkeypatch):
from logfire._internal.metrics import ProxyMeterProvider
from opentelemetry.metrics import NoOpMeterProvider
provider = NoOpMeterProvider()
proxy = ProxyMeterProvider(provider=provider)
assert runtime._registration_provider(proxy) is provider
monkeypatch.setattr(proxy, "add_metric_reader", lambda reader: None, raising=False)
assert runtime._registration_provider(proxy) is proxy
@pytest.mark.parametrize("wrapped_meter", [False, True])
def test_concurrent_provider_owner_wins(monkeypatch, wrapped_meter):
from opentelemetry import _logs, metrics, trace
from opentelemetry.sdk import _logs as sdk_logs
from opentelemetry.sdk import metrics as sdk_metrics
from opentelemetry.sdk import trace as sdk_trace
stopped = []
winners = []
for name, api, sdk, cls_name in [
("tracer", trace, sdk_trace, "TracerProvider"),
("meter", metrics, sdk_metrics, "MeterProvider"),
("logger", _logs, sdk_logs, "LoggerProvider"),
]:
original = getattr(sdk, cls_name)
winner = original(shutdown_on_exit=False)
winners.append(winner)
class Provider(original):
def shutdown(self, _name=name):
stopped.append(_name)
super().shutdown()
if wrapped_meter and name == "meter":
from logfire._internal.metrics import ProxyMeterProvider
winner = ProxyMeterProvider(winner)
current = [getattr(api, f"get_{name}_provider")()]
monkeypatch.setattr(sdk, cls_name, Provider)
monkeypatch.setattr(api, f"get_{name}_provider", lambda c=current: c[0])
monkeypatch.setattr(
api,
f"set_{name}_provider",
lambda provider, c=current, w=winner: c.__setitem__(0, w),
)
monkeypatch.setattr(runtime, "_owned", [])
monkeypatch.setattr(runtime, "_configured", [])
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:1")
runtime._configure_from_environment(FastAPI()._telemetry)
assert stopped == ["tracer", "meter", "logger"]
assert [provider for _, provider in runtime._configured] == winners
assert len(runtime._owned) == 3
assert all(component not in winners for component in runtime._owned)
runtime._shutdown()
for winner in winners:
winner.shutdown()
@pytest.mark.skipif(
not hasattr(__import__("os"), "fork"), reason="POSIX worker lifecycle"
)
@run_in_subprocess
def test_environment_export_initializes_after_fork():
import os
import threading
from http.server import BaseHTTPRequestHandler
from fastapi import FastAPI
from fastapi.telemetry import _runtime
from fastapi.testclient import TestClient
from tests.test_telemetry._otlp import _CollectorServer
received = []
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers["Content-Length"]))
received.append(self.path)
self.send_response(200)
self.end_headers()
def log_message(self, format, *args):
pass
server = _CollectorServer(("127.0.0.1", 0), Handler)
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = (
f"http://127.0.0.1:{server.server_port}/traces"
)
# Importing and constructing before fork must not create providers.
app = FastAPI()
assert not _runtime._owned
pid = os.fork()
if pid == 0:
try:
with TestClient(app) as client:
assert client.get("/").status_code == 404
assert len(_runtime._owned) == 1
_runtime._shutdown()
except BaseException: # pragma: no cover
os._exit(1)
os._exit(0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
_, status = os.waitpid(pid, 0)
assert status == 0, status
assert received == ["/traces"], received
assert not _runtime._owned
server.shutdown()
server.server_close()
@run_in_subprocess
def test_real_otlp_exception_export_without_traces_or_metrics():
import os
from fastapi import FastAPI
from fastapi.telemetry import _runtime
from fastapi.testclient import TestClient
from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
ExportLogsServiceRequest,
)
with otlp_collector() as (base, received):
os.environ["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"] = base + "/logs"
os.environ["OTEL_SERVICE_NAME"] = "errors-only"
app = FastAPI()
@app.get("/items/{item_id}")
def endpoint(item_id: int):
raise ValueError("exported exception")
try:
for _ in range(2):
with TestClient(app, raise_server_exceptions=False) as client:
assert client.get("/items/1").status_code == 500
assert len(_runtime._owned) == 1
assert all(path == "/logs" for path, _, _ in received)
requests = [
ExportLogsServiceRequest.FromString(body) for _, body, _ in received
]
records = [
record
for request in requests
for resource in request.resource_logs
for scope in resource.scope_logs
for record in scope.log_records
]
assert len(records) == 2
for record in records:
attributes = {a.key: a.value.string_value for a in record.attributes}
assert attributes["exception.type"] == "ValueError"
assert attributes["exception.message"] == "exported exception"
assert attributes["http.route"] == "/items/{item_id}"
assert "in endpoint" in attributes["exception.stacktrace"]
assert (
"ValueError: exported exception"
in attributes["exception.stacktrace"]
)
finally:
_runtime._shutdown()
@pytest.mark.parametrize("name", ["tracer", "meter", "logger"])
def test_unsupported_provider_warns_without_preventing_startup(
monkeypatch, caplog, name
):
from opentelemetry._logs import NoOpLoggerProvider
from opentelemetry.metrics import NoOpMeterProvider
from opentelemetry.trace import NoOpTracerProvider
signal, provider = {
"tracer": ("TRACES", NoOpTracerProvider),
"meter": ("METRICS", NoOpMeterProvider),
"logger": ("LOGS", NoOpLoggerProvider),
}[name]
monkeypatch.setenv(f"OTEL_EXPORTER_OTLP_{signal}_ENDPOINT", "http://127.0.0.1:1")
app = FastAPI(telemetry={f"{name}_provider": provider()})
with TestClient(app) as client:
assert client.get("/").status_code == 404
assert "does not support adding an OTLP exporter" in caplog.text
assert "auto_configure" in caplog.text
assert not runtime._owned
@pytest.mark.parametrize("error_type", [ValueError, AttributeError])
def test_failed_registration_closes_new_exporter(monkeypatch, caplog, error_type):
from opentelemetry.exporter.otlp.proto.http import trace_exporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter,
)
stopped = []
class Exporter(InMemorySpanExporter):
def __init__(self, *, endpoint):
super().__init__()
def shutdown(self):
stopped.append(True)
super().shutdown()
provider = TracerProvider(shutdown_on_exit=False)
def fail(component):
raise error_type("registration failed")
monkeypatch.setattr(provider, "add_span_processor", fail)
monkeypatch.setattr(trace_exporter, "OTLPSpanExporter", Exporter)
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://127.0.0.1:1")
app = FastAPI(telemetry={"tracer_provider": provider})
with TestClient(app) as client:
assert client.get("/").status_code == 404
assert "registration failed" in caplog.text
assert stopped == [True]
provider.shutdown()