From 9aac76313b428149638a060444fad553394afb4d Mon Sep 17 00:00:00 2001 From: Rick Stoopman Date: Mon, 6 Jul 2026 16:05:26 +0200 Subject: [PATCH 1/2] feat: add opt-in OpenTelemetry trace export Enable OTLP trace export for HTTP requests, upstream polling, and httpx calls when OTEL_EXPORTER_OTLP_ENDPOINT is set. Supports HTTP and gRPC exporters, health path exclusions, and trace/log correlation. Closes Flagsmith/edge-proxy#194 Co-authored-by: Cursor --- CHANGELOG.md | 7 + README.md | 36 +++- pyproject.toml | 6 + requirements.lock | 56 +++++++ src/edge_proxy/environments.py | 134 +++++++++------ src/edge_proxy/logging.py | 19 ++- src/edge_proxy/server.py | 26 ++- src/edge_proxy/telemetry.py | 141 ++++++++++++++++ tests/test_telemetry.py | 293 +++++++++++++++++++++++++++++++++ 9 files changed, 656 insertions(+), 62 deletions(-) create mode 100644 src/edge_proxy/telemetry.py create mode 100644 tests/test_telemetry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 398f07a..2e9653e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Features + +* add opt-in OpenTelemetry trace export (HTTP, upstream polling, httpx) + ([#194](https://github.com/Flagsmith/edge-proxy/issues/194)) + ## [2.23.0](https://github.com/Flagsmith/edge-proxy/compare/v2.22.0...v2.23.0) (2026-05-15) diff --git a/README.md b/README.md index bec76e7..99af185 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,41 @@ docker run \ -e CONFIG_PATH=/var/foo.json \ -v //config.json:/var/foo.json \ flagsmith/edge-proxy:latest -``` +``` + +## OpenTelemetry + +Edge Proxy supports opt-in distributed trace export over OTLP, aligned +with the [Flagsmith OpenTelemetry +docs](https://docs.flagsmith.com/deployment-self-hosting/observability/opentelemetry). + +Set `OTEL_EXPORTER_OTLP_ENDPOINT` to enable export. When unset, no +OpenTelemetry code is loaded and there is no runtime overhead. + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 # enables OTel +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # or grpc +OTEL_SERVICE_NAME=flagsmith-edge-proxy # default +OTEL_RESOURCE_ATTRIBUTES=deployment.environment=prod +OTEL_TRACES_EXPORTER=otlp # or none to disable +OTEL_SDK_DISABLED=true # hard disable +OTEL_TRACING_EXCLUDED_URL_PATHS=proxy/health/liveness,proxy/health/readiness +``` + +Example Docker run: + +```sh +docker run \ + -e OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \ + -e OTEL_EXPORTER_OTLP_PROTOCOL=grpc \ + -v ./config.json:/app/config.json \ + flagsmith/edge-proxy:latest +``` + +When enabled, traces cover incoming HTTP requests (FastAPI +auto-instrumentation), upstream environment-document polling, and +outbound httpx calls. Structured logs include `trace_id` and `span_id` +when an active span exists. ## Load Testing diff --git a/pyproject.toml b/pyproject.toml index 048ae00..7844290 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,12 @@ dependencies = [ "structlog", "uvicorn", "pydantic-settings>=2.2.1", + "opentelemetry-api>=1.25,<2", + "opentelemetry-sdk>=1.25,<2", + "opentelemetry-exporter-otlp-proto-http>=1.25,<2", + "opentelemetry-exporter-otlp-proto-grpc>=1.25,<2", + "opentelemetry-instrumentation-fastapi>=0.46b0,<1", + "opentelemetry-instrumentation-httpx>=0.46b0,<1", ] requires-python = ">= 3.12" diff --git a/requirements.lock b/requirements.lock index 61e9a07..6545a2c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -47,6 +47,62 @@ jsonpath-rfc9535==0.2.0 # via flagsmith-flag-engine marshmallow==3.21.1 # via edge-proxy +googleapis-common-protos==1.75.0 + # via opentelemetry-exporter-otlp-proto-grpc + # via opentelemetry-exporter-otlp-proto-http +grpcio==1.82.0 + # via opentelemetry-exporter-otlp-proto-grpc +opentelemetry-api==1.43.0 + # via edge-proxy + # via opentelemetry-exporter-otlp-proto-grpc + # via opentelemetry-exporter-otlp-proto-http + # via opentelemetry-instrumentation + # via opentelemetry-instrumentation-asgi + # via opentelemetry-instrumentation-fastapi + # via opentelemetry-instrumentation-httpx + # via opentelemetry-sdk + # via opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.43.0 + # via opentelemetry-exporter-otlp-proto-grpc + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-grpc==1.43.0 + # via edge-proxy +opentelemetry-exporter-otlp-proto-http==1.43.0 + # via edge-proxy +opentelemetry-instrumentation==0.64b0 + # via opentelemetry-instrumentation-asgi + # via opentelemetry-instrumentation-fastapi + # via opentelemetry-instrumentation-httpx +opentelemetry-instrumentation-asgi==0.64b0 + # via opentelemetry-instrumentation-fastapi +opentelemetry-instrumentation-fastapi==0.64b0 + # via edge-proxy +opentelemetry-instrumentation-httpx==0.64b0 + # via edge-proxy +opentelemetry-proto==1.43.0 + # via opentelemetry-exporter-otlp-proto-common + # via opentelemetry-exporter-otlp-proto-grpc + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.43.0 + # via edge-proxy + # via opentelemetry-exporter-otlp-proto-grpc + # via opentelemetry-exporter-otlp-proto-http +opentelemetry-semantic-conventions==0.64b0 + # via opentelemetry-instrumentation + # via opentelemetry-instrumentation-asgi + # via opentelemetry-instrumentation-fastapi + # via opentelemetry-instrumentation-httpx + # via opentelemetry-sdk +opentelemetry-util-http==0.64b0 + # via opentelemetry-instrumentation-asgi + # via opentelemetry-instrumentation-fastapi + # via opentelemetry-instrumentation-httpx +protobuf==7.35.1 + # via googleapis-common-protos + # via opentelemetry-proto +wrapt==2.2.2 + # via opentelemetry-instrumentation + # via opentelemetry-instrumentation-httpx orjson==3.10.0 # via edge-proxy packaging==24.0 diff --git a/src/edge_proxy/environments.py b/src/edge_proxy/environments.py index 880930e..8681c5a 100644 --- a/src/edge_proxy/environments.py +++ b/src/edge_proxy/environments.py @@ -28,6 +28,7 @@ ) from edge_proxy.models import IdentityWithTraits from edge_proxy.settings import AppSettings, EnvironmentKeyPair +from edge_proxy.telemetry import get_tracer logger = structlog.get_logger(__name__) @@ -64,22 +65,30 @@ def __init__( )(self.get_identity_response_data) async def refresh_environment_caches(self): - received_error = False - for key_pair in self.settings.environment_key_pairs: - try: - environment_document = await self._fetch_document(key_pair) - if self.cache.put_environment( - environment_api_key=key_pair.client_side_key, - environment_document=environment_document, - ): - await self._clear_endpoint_caches() - except (httpx.HTTPError, orjson.JSONDecodeError): - logger.exception( - "error_fetching_document", client_side_key=key_pair.client_side_key - ) - received_error = True - if not received_error: - self.last_updated_at = datetime.now() + with get_tracer().start_as_current_span( + "poll.refresh_environment_caches", + ) as span: + span.set_attribute( + "environments.count", + len(self.settings.environment_key_pairs), + ) + received_error = False + for key_pair in self.settings.environment_key_pairs: + try: + environment_document = await self._fetch_document(key_pair) + if self.cache.put_environment( + environment_api_key=key_pair.client_side_key, + environment_document=environment_document, + ): + await self._clear_endpoint_caches() + except (httpx.HTTPError, orjson.JSONDecodeError): + logger.exception( + "error_fetching_document", + client_side_key=key_pair.client_side_key, + ) + received_error = True + if not received_error: + self.last_updated_at = datetime.now() def get_flags_response_data( self, environment_key: str, feature: str | None = None @@ -95,7 +104,13 @@ def get_flags_response_data( hide_disabled_flags = _get_hide_disabled_flags(environment_document) context = map_environment_document_to_context(environment_document) - evaluation_result = get_evaluation_result(context) + with get_tracer().start_as_current_span("evaluate.flags") as span: + span.set_attribute("flagsmith.single_feature", feature is not None) + evaluation_result = get_evaluation_result(context) + span.set_attribute( + "flagsmith.feature_count", + len(evaluation_result["flags"]), + ) feature_types = self.cache.get_feature_types(environment_key) if feature_types is None: @@ -146,7 +161,12 @@ def get_identity_response_data( identifier=input_data.identifier, traits=convert_traits_to_dict(input_data.traits), ) - evaluation_result = get_evaluation_result(context) + with get_tracer().start_as_current_span("evaluate.identity") as span: + evaluation_result = get_evaluation_result(context) + span.set_attribute( + "flagsmith.feature_count", + len(evaluation_result["flags"]), + ) feature_types = self.cache.get_feature_types(environment_key) if feature_types is None: @@ -179,40 +199,56 @@ def get_environment( raise FlagsmithUnknownKeyError(environment_key) async def _fetch_document(self, key_pair: EnvironmentKeyPair) -> dict[str, Any]: - headers = { - "X-Environment-Key": key_pair.server_side_key, - } - environment_document = self.cache.get_environment( - environment_api_key=key_pair.client_side_key - ) - if environment_document: - updated_at: str = environment_document.get("updated_at") - if updated_at: - try: - epoch_seconds = datetime.fromisoformat(updated_at).timestamp() - # Same implementation as https://docs.djangoproject.com/en/4.2/ref/utils/#django.utils.http.http_date - headers["If-Modified-Since"] = formatdate( - epoch_seconds, usegmt=True - ) - except ValueError: + with get_tracer().start_as_current_span("poll.fetch_document") as span: + headers = { + "X-Environment-Key": key_pair.server_side_key, + } + environment_document = self.cache.get_environment( + environment_api_key=key_pair.client_side_key + ) + if environment_document: + updated_at: str = environment_document.get("updated_at") + if updated_at: + try: + epoch_seconds = datetime.fromisoformat( + updated_at, + ).timestamp() + # Same implementation as + # https://docs.djangoproject.com/en/4.2/ref/utils/ + # #django.utils.http.http_date + headers["If-Modified-Since"] = formatdate( + epoch_seconds, usegmt=True + ) + except ValueError: + logger.warning( + "failed to parse updated_at, " + f"environment={key_pair.client_side_key} " + f"updated_at={updated_at}" + ) + else: logger.warning( - f"failed to parse updated_at, environment={key_pair.client_side_key} updated_at={updated_at}" + "received environment with no updated_at: " + f"{key_pair.client_side_key}" ) - else: - logger.warning( - f"received environment with no updated_at: {key_pair.client_side_key}" + try: + response = await self._client.get( + url=f"{self.settings.api_url}/environment-document/", + headers=headers, ) - response = await self._client.get( - url=f"{self.settings.api_url}/environment-document/", - headers=headers, - ) - if response.status_code == starlette.status.HTTP_304_NOT_MODIFIED: - assert environment_document, ( - f"GET /environment-document returned 304 without a cached document. environment={key_pair.client_side_key}" - ) - return environment_document - response.raise_for_status() - return orjson.loads(response.text) + span.set_attribute("http.status_code", response.status_code) + if response.status_code == starlette.status.HTTP_304_NOT_MODIFIED: + span.set_attribute("flagsmith.cache_hit", True) + assert environment_document, ( + "GET /environment-document returned 304 without a " + f"cached document. environment={key_pair.client_side_key}" + ) + return environment_document + span.set_attribute("flagsmith.cache_hit", False) + response.raise_for_status() + return orjson.loads(response.text) + except (httpx.HTTPError, orjson.JSONDecodeError) as exc: + span.record_exception(exc) + raise async def _clear_endpoint_caches(self): for func in (self.get_identity_response_data, self.get_flags_response_data): diff --git a/src/edge_proxy/logging.py b/src/edge_proxy/logging.py index 6fb6cd0..f7d4aa3 100644 --- a/src/edge_proxy/logging.py +++ b/src/edge_proxy/logging.py @@ -5,6 +5,7 @@ import structlog from edge_proxy.settings import LogFormat, LoggingSettings +from edge_proxy.telemetry import add_otel_trace_context def _extract_gunicorn_access_log_event( @@ -49,13 +50,17 @@ def _drop_color_message( ] -def setup_logging(settings: LoggingSettings) -> None: - processors = [ - *COMMON_PROCESSORS, - structlog.processors.EventRenamer(settings.log_event_field_name), - structlog.dev.set_exc_info, - structlog.processors.format_exc_info, - ] +def setup_logging(settings: LoggingSettings, *, otel_enabled: bool = False) -> None: + processors: list[structlog.types.Processor] = [*COMMON_PROCESSORS] + if otel_enabled: + processors.append(add_otel_trace_context) + processors.extend( + [ + structlog.processors.EventRenamer(settings.log_event_field_name), + structlog.dev.set_exc_info, + structlog.processors.format_exc_info, + ], + ) structlog.configure( processors=processors diff --git a/src/edge_proxy/server.py b/src/edge_proxy/server.py index 46e8295..25799da 100644 --- a/src/edge_proxy/server.py +++ b/src/edge_proxy/server.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta import asyncio +import os import httpx from contextlib import asynccontextmanager @@ -16,15 +17,24 @@ from edge_proxy.logging import setup_logging from edge_proxy.models import IdentityWithTraits from edge_proxy.settings import get_settings +from edge_proxy.telemetry import ( + instrument_fastapi, + instrument_httpx, + setup_telemetry, +) settings = get_settings() -setup_logging(settings.logging) +telemetry = setup_telemetry() +setup_logging(settings.logging, otel_enabled=telemetry.enabled) +http_client = httpx.AsyncClient( + timeout=settings.api_poll_timeout_seconds, + follow_redirects=True, +) +if telemetry.enabled: + instrument_httpx() environment_service = EnvironmentService( LocalMemEnvironmentsCache(), - httpx.AsyncClient( - timeout=settings.api_poll_timeout_seconds, - follow_redirects=True, - ), + http_client, settings, ) @@ -147,3 +157,9 @@ async def environment_document( allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=1000) + +if telemetry.enabled: + instrument_fastapi( + app, + excluded_urls=os.environ.get("OTEL_TRACING_EXCLUDED_URL_PATHS"), + ) diff --git a/src/edge_proxy/telemetry.py b/src/edge_proxy/telemetry.py new file mode 100644 index 0000000..cc431ce --- /dev/null +++ b/src/edge_proxy/telemetry.py @@ -0,0 +1,141 @@ +import os +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import FastAPI + +DEFAULT_SERVICE_NAME = "flagsmith-edge-proxy" +_TRACER_NAME = "edge_proxy" + +_telemetry_setup: "TelemetrySetup | None" = None + + +@dataclass(frozen=True) +class TelemetrySetup: + enabled: bool + + +def is_otel_enabled() -> bool: + if os.environ.get("OTEL_SDK_DISABLED", "").lower() == "true": + return False + if os.environ.get("OTEL_TRACES_EXPORTER", "otlp").lower() == "none": + return False + return bool(os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")) + + +def setup_telemetry() -> TelemetrySetup: + global _telemetry_setup + if _telemetry_setup is not None: + return _telemetry_setup + + if not is_otel_enabled(): + _telemetry_setup = TelemetrySetup(enabled=False) + return _telemetry_setup + + from opentelemetry import trace + from opentelemetry.baggage.propagation import W3CBaggagePropagator + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcOTLPSpanExporter, + ) + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpOTLPSpanExporter, + ) + from opentelemetry.propagate import set_global_textmap + from opentelemetry.propagators.composite import CompositePropagator + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.semconv.resource import ResourceAttributes + from opentelemetry.trace.propagation.tracecontext import ( + TraceContextTextMapPropagator, + ) + + service_name = os.environ.get("OTEL_SERVICE_NAME", DEFAULT_SERVICE_NAME) + resource = Resource.create( + {ResourceAttributes.SERVICE_NAME: service_name}, + ) + provider = TracerProvider(resource=resource) + trace.set_tracer_provider(provider) + + protocol = os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + if protocol == "grpc": + exporter = GrpcOTLPSpanExporter() + else: + exporter = HttpOTLPSpanExporter() + + provider.add_span_processor(BatchSpanProcessor(exporter)) + set_global_textmap( + CompositePropagator( + [TraceContextTextMapPropagator(), W3CBaggagePropagator()], + ), + ) + + _telemetry_setup = TelemetrySetup(enabled=True) + return _telemetry_setup + + +class _NoOpSpan: + def set_attribute(self, *args, **kwargs) -> None: + pass + + def record_exception(self, *args, **kwargs) -> None: + pass + + +class _NoOpSpanContext: + def __enter__(self) -> _NoOpSpan: + return _NoOpSpan() + + def __exit__(self, *args) -> None: + pass + + +class _NoOpTracer: + def start_as_current_span(self, name: str) -> _NoOpSpanContext: + return _NoOpSpanContext() + + +_no_op_tracer = _NoOpTracer() + + +def get_tracer(): + if not is_otel_enabled(): + return _no_op_tracer + + setup_telemetry() + from opentelemetry import trace + + return trace.get_tracer(_TRACER_NAME) + + +def instrument_fastapi(app: "FastAPI", excluded_urls: str | None = None) -> None: + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + + FastAPIInstrumentor.instrument_app(app, excluded_urls=excluded_urls) + + +def instrument_httpx() -> None: + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + + HTTPXClientInstrumentor().instrument() + + +def add_otel_trace_context( + logger: object, + method_name: str, + event_dict: dict, +) -> dict: + if not is_otel_enabled(): + return event_dict + + from opentelemetry import trace + + span = trace.get_current_span() + if not span or not span.is_recording(): + return event_dict + + ctx = span.get_span_context() + event_dict["trace_id"] = format(ctx.trace_id, "032x") + event_dict["span_id"] = format(ctx.span_id, "016x") + return event_dict diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..26d7ea9 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,293 @@ +import pytest +import starlette.status +from fastapi import FastAPI +from fastapi.testclient import TestClient +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from pytest_mock import MockerFixture + +from edge_proxy.environments import EnvironmentService +from edge_proxy.settings import AppSettings +from edge_proxy.telemetry import ( + add_otel_trace_context, + get_tracer, + is_otel_enabled, + setup_telemetry, +) +from tests.fixtures.response_data import environment_1, environment_1_api_key + + +def _reset_tracer_provider_lock() -> None: + import opentelemetry.trace as otel_trace + + otel_trace._TRACER_PROVIDER_SET_ONCE._done = False + + +@pytest.fixture +def reset_telemetry(monkeypatch: pytest.MonkeyPatch): + import edge_proxy.telemetry as telemetry_module + + _reset_tracer_provider_lock() + telemetry_module._telemetry_setup = None + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + monkeypatch.delenv("OTEL_TRACES_EXPORTER", raising=False) + monkeypatch.delenv("OTEL_TRACING_EXCLUDED_URL_PATHS", raising=False) + yield + _reset_tracer_provider_lock() + telemetry_module._telemetry_setup = None + + +@pytest.fixture +def span_exporter(monkeypatch: pytest.MonkeyPatch, reset_telemetry): + monkeypatch.setenv( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "http://localhost:4318", + ) + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + _reset_tracer_provider_lock() + trace.set_tracer_provider(provider) + + import edge_proxy.telemetry as telemetry_module + + telemetry_module._telemetry_setup = telemetry_module.TelemetrySetup( + enabled=True, + ) + + yield exporter + + provider.force_flush() + exporter.clear() + + +def test_is_otel_enabled_by_default(reset_telemetry): + assert is_otel_enabled() is False + assert setup_telemetry().enabled is False + + +def test_is_otel_disabled_when_sdk_disabled( + monkeypatch: pytest.MonkeyPatch, + reset_telemetry, +): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + + assert is_otel_enabled() is False + + +def test_is_otel_disabled_when_traces_exporter_none( + monkeypatch: pytest.MonkeyPatch, + reset_telemetry, +): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + monkeypatch.setenv("OTEL_TRACES_EXPORTER", "none") + + assert is_otel_enabled() is False + + +def test_setup_telemetry_enables_when_endpoint_set( + monkeypatch: pytest.MonkeyPatch, + reset_telemetry, + mocker: MockerFixture, +): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + mocker.patch( + "opentelemetry.sdk.trace.export.BatchSpanProcessor", + ) + + assert setup_telemetry().enabled is True + + +@pytest.mark.asyncio +async def test_refresh_environment_caches_creates_poll_spans( + mocker: MockerFixture, + span_exporter: InMemorySpanExporter, +): + mock_client = mocker.AsyncMock() + mock_client.get.return_value = mocker.Mock( + status_code=200, + text='{"updated_at": "2024-01-01T00:00:00"}', + ) + settings = AppSettings( + api_url="http://127.0.0.1:8000/api/v1", + environment_key_pairs=[ + { + "server_side_key": "ser.key1", + "client_side_key": environment_1_api_key, + }, + ], + ) + environment_service = EnvironmentService( + client=mock_client, + settings=settings, + ) + + await environment_service.refresh_environment_caches() + + trace.get_tracer_provider().force_flush() + span_names = [span.name for span in span_exporter.get_finished_spans()] + assert "poll.refresh_environment_caches" in span_names + assert "poll.fetch_document" in span_names + + +@pytest.mark.asyncio +async def test_fetch_document_304_sets_cache_hit_attribute( + mocker: MockerFixture, + span_exporter: InMemorySpanExporter, +): + mock_client = mocker.AsyncMock() + mock_client.get.return_value = mocker.Mock( + status_code=starlette.status.HTTP_304_NOT_MODIFIED, + text="", + ) + settings = AppSettings( + api_url="http://127.0.0.1:8000/api/v1", + environment_key_pairs=[ + { + "server_side_key": "ser.key1", + "client_side_key": environment_1_api_key, + }, + ], + ) + environment_service = EnvironmentService( + client=mock_client, + settings=settings, + ) + environment_service.cache.put_environment( + environment_1_api_key, + {**environment_1, "updated_at": "2024-01-01T00:00:00"}, + ) + + await environment_service.refresh_environment_caches() + + trace.get_tracer_provider().force_flush() + fetch_spans = [ + span + for span in span_exporter.get_finished_spans() + if span.name == "poll.fetch_document" + ] + assert len(fetch_spans) == 1 + attributes = dict(fetch_spans[0].attributes) + assert attributes["flagsmith.cache_hit"] is True + assert attributes["http.status_code"] == 304 + + +def test_fastapi_instrumentation_creates_http_span( + span_exporter: InMemorySpanExporter, +): + from edge_proxy.telemetry import instrument_fastapi + + test_app = FastAPI() + + @test_app.get("/api/v1/flags/") + async def flags(): + return {"flags": []} + + instrument_fastapi(test_app) + + client = TestClient(test_app) + response = client.get("/api/v1/flags/") + assert response.status_code == 200 + + trace.get_tracer_provider().force_flush() + http_spans = [ + span + for span in span_exporter.get_finished_spans() + if span.attributes.get("http.method") == "GET" + ] + assert http_spans + assert http_spans[0].attributes.get("http.status_code") == 200 + + +def test_health_liveness_excluded_from_tracing( + monkeypatch: pytest.MonkeyPatch, + span_exporter: InMemorySpanExporter, +): + from edge_proxy.telemetry import instrument_fastapi + + monkeypatch.setenv( + "OTEL_TRACING_EXCLUDED_URL_PATHS", + "proxy/health/liveness", + ) + + test_app = FastAPI() + + @test_app.get("/proxy/health/liveness") + async def liveness(): + return {"status": "ok"} + + instrument_fastapi( + test_app, + excluded_urls="proxy/health/liveness", + ) + + client = TestClient(test_app) + response = client.get("/proxy/health/liveness") + assert response.status_code == 200 + + trace.get_tracer_provider().force_flush() + assert not span_exporter.get_finished_spans() + + +def test_add_otel_trace_context_injects_ids( + monkeypatch: pytest.MonkeyPatch, + span_exporter: InMemorySpanExporter, +): + tracer = get_tracer() + with tracer.start_as_current_span("test.span"): + event_dict = add_otel_trace_context(None, "", {"message": "test"}) + + assert "trace_id" in event_dict + assert "span_id" in event_dict + assert len(event_dict["trace_id"]) == 32 + assert len(event_dict["span_id"]) == 16 + + +def test_add_otel_trace_context_noop_when_disabled(reset_telemetry): + event_dict = add_otel_trace_context(None, "", {"message": "test"}) + assert event_dict == {"message": "test"} + + +def test_setup_telemetry_uses_grpc_exporter( + monkeypatch: pytest.MonkeyPatch, + reset_telemetry, + mocker: MockerFixture, +): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + mock_grpc_exporter = mocker.patch( + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter." + "OTLPSpanExporter", + ) + mocker.patch( + "opentelemetry.sdk.trace.export.BatchSpanProcessor", + ) + + setup_telemetry() + + mock_grpc_exporter.assert_called_once() + + +def test_setup_telemetry_uses_http_exporter_by_default( + monkeypatch: pytest.MonkeyPatch, + reset_telemetry, + mocker: MockerFixture, +): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + mock_http_exporter = mocker.patch( + "opentelemetry.exporter.otlp.proto.http.trace_exporter." + "OTLPSpanExporter", + ) + mocker.patch( + "opentelemetry.sdk.trace.export.BatchSpanProcessor", + ) + + setup_telemetry() + + mock_http_exporter.assert_called_once() From 44ebc0faed07d52aef26f214da67708948a305e0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:05:55 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_telemetry.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 26d7ea9..e17996a 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -262,8 +262,7 @@ def test_setup_telemetry_uses_grpc_exporter( monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") mock_grpc_exporter = mocker.patch( - "opentelemetry.exporter.otlp.proto.grpc.trace_exporter." - "OTLPSpanExporter", + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter.OTLPSpanExporter", ) mocker.patch( "opentelemetry.sdk.trace.export.BatchSpanProcessor", @@ -281,8 +280,7 @@ def test_setup_telemetry_uses_http_exporter_by_default( ): monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") mock_http_exporter = mocker.patch( - "opentelemetry.exporter.otlp.proto.http.trace_exporter." - "OTLPSpanExporter", + "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter", ) mocker.patch( "opentelemetry.sdk.trace.export.BatchSpanProcessor",