diff --git a/api_server_main.py b/api_server_main.py index d94cf22..a5f375d 100644 --- a/api_server_main.py +++ b/api_server_main.py @@ -7,7 +7,7 @@ from cloud_pipelines_backend import database_ops from cloud_pipelines_backend.instrumentation import api_tracing from cloud_pipelines_backend.instrumentation import contextual_logging -from cloud_pipelines_backend.instrumentation import otel_tracing +from cloud_pipelines_backend.instrumentation import opentelemetry as otel app = fastapi.FastAPI( title="Cloud Pipelines API", @@ -15,8 +15,8 @@ separate_input_output_schemas=False, ) -# Configure OpenTelemetry tracing -otel_tracing.setup_api_tracing(app) +otel.setup_providers() +otel.instrument_fastapi(app) # Add request context middleware for automatic request_id generation app.add_middleware(api_tracing.RequestContextMiddleware) diff --git a/cloud_pipelines_backend/instrumentation/opentelemetry/__init__.py b/cloud_pipelines_backend/instrumentation/opentelemetry/__init__.py new file mode 100644 index 0000000..fad5a3a --- /dev/null +++ b/cloud_pipelines_backend/instrumentation/opentelemetry/__init__.py @@ -0,0 +1,24 @@ +""" +OpenTelemetry instrumentation public API. + +Usage:: + + from cloud_pipelines_backend.instrumentation import opentelemetry as otel + + otel.setup_providers() + otel.instrument_fastapi(app) +""" + +from cloud_pipelines_backend.instrumentation.opentelemetry import auto_instrumentation +from cloud_pipelines_backend.instrumentation.opentelemetry import providers +from cloud_pipelines_backend.instrumentation.opentelemetry import tracing + +instrument_fastapi = auto_instrumentation.instrument_fastapi +setup_providers = providers.setup +setup_tracing = tracing.setup + +__all__ = [ + "instrument_fastapi", + "setup_providers", + "setup_tracing", +] diff --git a/cloud_pipelines_backend/instrumentation/opentelemetry/_internal/configuration.py b/cloud_pipelines_backend/instrumentation/opentelemetry/_internal/configuration.py new file mode 100644 index 0000000..d7218ff --- /dev/null +++ b/cloud_pipelines_backend/instrumentation/opentelemetry/_internal/configuration.py @@ -0,0 +1,59 @@ +""" +Shared OpenTelemetry configuration resolution. + +Reads and validates OTel settings from environment variables. +""" + +import dataclasses +import enum +import os + + +class ExporterProtocol(str, enum.Enum): + GRPC = "grpc" + HTTP = "http" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class OtelConfig: + endpoint: str + protocol: str + service_name: str + + +def resolve(service_name: str | None = None) -> OtelConfig | None: + """Read and validate shared OTel configuration from environment variables. + + Returns None if OTel is not configured (no exporter endpoint set). + Raises ValueError if the configuration is invalid. + """ + otel_endpoint = os.environ.get("TANGLE_OTEL_EXPORTER_ENDPOINT") + if not otel_endpoint: + return None + + otel_protocol = os.environ.get( + "TANGLE_OTEL_EXPORTER_PROTOCOL", ExporterProtocol.GRPC + ) + + if service_name is None: + app_env = os.environ.get("TANGLE_ENV", "unknown") + service_name = f"tangle-{app_env}" + + if not otel_endpoint.startswith(("http://", "https://")): + raise ValueError( + f"Invalid OTel endpoint format: {otel_endpoint}. " + f"Expected format: http://: or https://:" + ) + try: + ExporterProtocol(otel_protocol) + except ValueError: + raise ValueError( + f"Invalid OTel protocol: {otel_protocol}. " + f"Expected values: {', '.join(e.value for e in ExporterProtocol)}" + ) + + return OtelConfig( + endpoint=otel_endpoint, + protocol=otel_protocol, + service_name=service_name, + ) diff --git a/cloud_pipelines_backend/instrumentation/opentelemetry/_internal/providers.py b/cloud_pipelines_backend/instrumentation/opentelemetry/_internal/providers.py new file mode 100644 index 0000000..ec8ac5a --- /dev/null +++ b/cloud_pipelines_backend/instrumentation/opentelemetry/_internal/providers.py @@ -0,0 +1,21 @@ +""" +OpenTelemetry provider state checks. + +Queries the global OpenTelemetry SDK state to determine which +providers have been configured. +""" + +from opentelemetry import metrics as otel_metrics +from opentelemetry import trace +from opentelemetry.sdk import metrics as otel_sdk_metrics +from opentelemetry.sdk import trace as otel_sdk_trace + + +def has_configured_providers() -> bool: + """Check whether any OpenTelemetry SDK providers have been configured globally. + + Logs provider is omitted while the OpenTelemetry Logs API remains experimental. + """ + return isinstance( + trace.get_tracer_provider(), otel_sdk_trace.TracerProvider + ) or isinstance(otel_metrics.get_meter_provider(), otel_sdk_metrics.MeterProvider) diff --git a/cloud_pipelines_backend/instrumentation/opentelemetry/auto_instrumentation.py b/cloud_pipelines_backend/instrumentation/opentelemetry/auto_instrumentation.py new file mode 100644 index 0000000..5303197 --- /dev/null +++ b/cloud_pipelines_backend/instrumentation/opentelemetry/auto_instrumentation.py @@ -0,0 +1,40 @@ +""" +OpenTelemetry auto-instrumentation for FastAPI applications. + +Instrumentation is only activated when at least one OpenTelemetry SDK +provider (traces, metrics, or logs) has been configured globally. +""" + +import fastapi +import logging + +from opentelemetry.instrumentation import fastapi as otel_fastapi + +from cloud_pipelines_backend.instrumentation.opentelemetry._internal import providers + +_logger = logging.getLogger(__name__) + + +def instrument_fastapi(app: fastapi.FastAPI) -> None: + """ + Apply OpenTelemetry auto-instrumentation to a FastAPI application. + + No-op if no OpenTelemetry SDK providers have been configured globally, + since there would be no backend to receive the telemetry data. + + See: https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html + + Args: + app: The FastAPI application instance to instrument. + """ + if not providers.has_configured_providers(): + _logger.debug( + "Skipping FastAPI auto-instrumentation: no OpenTelemetry providers configured" + ) + return + + try: + otel_fastapi.FastAPIInstrumentor.instrument_app(app) + _logger.info("FastAPI auto-instrumentation enabled") + except Exception as e: + _logger.exception("Failed to apply FastAPI auto-instrumentation") diff --git a/cloud_pipelines_backend/instrumentation/opentelemetry/providers.py b/cloud_pipelines_backend/instrumentation/opentelemetry/providers.py new file mode 100644 index 0000000..8d33d9c --- /dev/null +++ b/cloud_pipelines_backend/instrumentation/opentelemetry/providers.py @@ -0,0 +1,42 @@ +""" +OpenTelemetry provider setup. + +Provides entry points to configure OpenTelemetry providers. +""" + +import logging + +from cloud_pipelines_backend.instrumentation.opentelemetry._internal import configuration +from cloud_pipelines_backend.instrumentation.opentelemetry import tracing + +_logger = logging.getLogger(__name__) + + +def setup(service_name: str | None = None) -> None: + """ + Configure global OpenTelemetry providers (traces, metrics). + + No-op if TANGLE_OTEL_EXPORTER_ENDPOINT is not set. + + Use this for non-FastAPI entrypoints (e.g. orchestrators, workers) that + need telemetry but have no ASGI app to auto-instrument. + + Args: + service_name: Override the default service name reported to the collector. + """ + try: + otel_config = configuration.resolve(service_name=service_name) + except Exception as e: + _logger.exception("Failed to resolve OpenTelemetry configuration") + return + + if otel_config is None: + return + + tracing.setup( + endpoint=otel_config.endpoint, + protocol=otel_config.protocol, + service_name=otel_config.service_name, + ) + + # TODO: Setup metrics provider once it's available diff --git a/cloud_pipelines_backend/instrumentation/opentelemetry/tracing.py b/cloud_pipelines_backend/instrumentation/opentelemetry/tracing.py new file mode 100644 index 0000000..320e164 --- /dev/null +++ b/cloud_pipelines_backend/instrumentation/opentelemetry/tracing.py @@ -0,0 +1,55 @@ +""" +OpenTelemetry tracing configuration. + +This module sets up the global tracer provider with an OTLP exporter. +""" + +import logging + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc import ( + trace_exporter as otel_grpc_trace_exporter, +) +from opentelemetry.exporter.otlp.proto.http import ( + trace_exporter as otel_http_trace_exporter, +) +from opentelemetry.sdk import resources as otel_resources +from opentelemetry.sdk import trace as otel_trace +from opentelemetry.sdk.trace import export as otel_trace_export + +from cloud_pipelines_backend.instrumentation.opentelemetry._internal import configuration + +_logger = logging.getLogger(__name__) + + +def setup(endpoint: str, protocol: str, service_name: str) -> None: + """ + Configure the global OpenTelemetry tracer provider. + + Args: + endpoint: The OTLP collector endpoint URL. + protocol: The exporter protocol ("grpc" or "http"). + service_name: The service name reported to the collector. + """ + try: + _logger.info( + f"Configuring OpenTelemetry tracing, endpoint={endpoint}, " + f"protocol={protocol}, service_name={service_name}" + ) + + if protocol == configuration.ExporterProtocol.GRPC: + otel_exporter = otel_grpc_trace_exporter.OTLPSpanExporter(endpoint=endpoint) + else: + otel_exporter = otel_http_trace_exporter.OTLPSpanExporter(endpoint=endpoint) + + resource = otel_resources.Resource( + attributes={otel_resources.SERVICE_NAME: service_name} + ) + tracer_provider = otel_trace.TracerProvider(resource=resource) + span_processor = otel_trace_export.BatchSpanProcessor(otel_exporter) + tracer_provider.add_span_processor(span_processor) + trace.set_tracer_provider(tracer_provider) + + _logger.info("OpenTelemetry tracing configured successfully.") + except Exception as e: + _logger.exception("Failed to configure OpenTelemetry tracing") diff --git a/cloud_pipelines_backend/instrumentation/otel_tracing.py b/cloud_pipelines_backend/instrumentation/otel_tracing.py deleted file mode 100644 index 08f9afa..0000000 --- a/cloud_pipelines_backend/instrumentation/otel_tracing.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -OpenTelemetry tracing configuration for FastAPI applications. - -This module sets up distributed tracing with OTLP exporter for sending traces -to an OpenTelemetry collector endpoint. -""" - -import fastapi -import logging -import os - -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.grpc import ( - trace_exporter as otel_grpc_trace_exporter, -) -from opentelemetry.exporter.otlp.proto.http import ( - trace_exporter as otel_http_trace_exporter, -) -from opentelemetry.instrumentation import fastapi as otel_fastapi -from opentelemetry.sdk import resources as otel_resources -from opentelemetry.sdk import trace as otel_trace -from opentelemetry.sdk.trace import export as otel_trace_export - -_logger = logging.getLogger(__name__) - -_OTEL_PROTOCOL_GRPC = "grpc" -_OTEL_PROTOCOL_HTTP = "http" -_OTEL_PROTOCOLS = (_OTEL_PROTOCOL_GRPC, _OTEL_PROTOCOL_HTTP) - - -def setup_api_tracing(app: fastapi.FastAPI) -> None: - """ - Configure OpenTelemetry tracing for a FastAPI application. - - Args: - app: The FastAPI application instance to instrument - - Environment Variables: - TANGLE_OTEL_EXPORTER_ENDPOINT: The endpoint URL for the OTLP collector - (e.g., "http://localhost:4317") - TANGLE_ENV: Environment name to include in service name - (defaults to "development") - TANGLE_OTEL_EXPORTER_PROTOCOL: The protocol to use for the OTLP exporter - (defaults to "grpc", can be "http") - """ - otel_endpoint = os.environ.get("TANGLE_OTEL_EXPORTER_ENDPOINT") - if not otel_endpoint: - return - - app_env = os.environ.get("TANGLE_ENV", "development") - otel_protocol = os.environ.get("TANGLE_OTEL_EXPORTER_PROTOCOL", _OTEL_PROTOCOL_GRPC) - service_name = f"tangle-{app_env}" - - try: - _logger.info( - f"Configuring OpenTelemetry tracing, endpoint={otel_endpoint}, protocol={otel_protocol}, service_name={service_name}" - ) - - _validate_otel_config(otel_endpoint, otel_protocol) - - if otel_protocol == _OTEL_PROTOCOL_GRPC: - otel_exporter = otel_grpc_trace_exporter.OTLPSpanExporter( - endpoint=otel_endpoint - ) - else: - otel_exporter = otel_http_trace_exporter.OTLPSpanExporter( - endpoint=otel_endpoint - ) - - resource = otel_resources.Resource( - attributes={otel_resources.SERVICE_NAME: service_name} - ) - tracer_provider = otel_trace.TracerProvider(resource=resource) - span_processor = otel_trace_export.BatchSpanProcessor(otel_exporter) - tracer_provider.add_span_processor(span_processor) - trace.set_tracer_provider(tracer_provider) - - # FastAPI auto-instrumentation docs: - # https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/fastapi/fastapi.html - otel_fastapi.FastAPIInstrumentor.instrument_app(app) - - _logger.info(f"OpenTelemetry tracing configured successfully.") - - except Exception as e: - _logger.exception(f"Failed to configure OpenTelemetry tracing: {e}") - - -def _validate_otel_config(otel_endpoint: str, otel_protocol: str) -> None: - """Validate OTel configuration. Raises ValueError if invalid.""" - if not otel_endpoint.startswith(("http://", "https://")): - raise ValueError( - f"Invalid OTel endpoint format: {otel_endpoint}. Expected format: http://: or https://:" - ) - if otel_protocol not in _OTEL_PROTOCOLS: - raise ValueError( - f"Invalid OTel protocol: {otel_protocol}. Expected values: {_OTEL_PROTOCOL_GRPC}, {_OTEL_PROTOCOL_HTTP}" - ) diff --git a/pyproject.toml b/pyproject.toml index f4f36ea..7da8c8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ huggingface = [ "huggingface-hub[oauth]>=0.35.3", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--import-mode=importlib" [tool.setuptools.packages.find] include = ["cloud_pipelines_backend*"] namespaces = true diff --git a/start_local.py b/start_local.py index 1155c15..4ea8921 100644 --- a/start_local.py +++ b/start_local.py @@ -132,6 +132,12 @@ def get_user_details(request: fastapi.Request): logger = logging.getLogger(__name__) # endregion +# region: OpenTelemetry initialization +from cloud_pipelines_backend.instrumentation import opentelemetry as otel + +otel.setup_providers() +# endregion + # region: Database engine initialization from cloud_pipelines_backend import database_ops @@ -217,7 +223,6 @@ def run_orchestrator( from cloud_pipelines_backend import database_ops from cloud_pipelines_backend.instrumentation import api_tracing from cloud_pipelines_backend.instrumentation import contextual_logging -from cloud_pipelines_backend.instrumentation import otel_tracing @contextlib.asynccontextmanager @@ -243,8 +248,7 @@ async def lifespan(app: fastapi.FastAPI): lifespan=lifespan, ) -# Configure OpenTelemetry tracing -otel_tracing.setup_api_tracing(app) +otel.instrument_fastapi(app) # Add request context middleware for automatic request_id generation app.add_middleware(api_tracing.RequestContextMiddleware) diff --git a/tests/instrumentation/opentelemetry/_internal/conftest.py b/tests/instrumentation/opentelemetry/_internal/conftest.py new file mode 100644 index 0000000..8576f23 --- /dev/null +++ b/tests/instrumentation/opentelemetry/_internal/conftest.py @@ -0,0 +1,14 @@ +import pytest +from opentelemetry import trace + + +@pytest.fixture(autouse=True) +def reset_otel_tracer_provider(): + """Reset the global OTel tracer provider between tests. + + OTel only allows set_tracer_provider to be called once per process. + We reset the internal guard so each test gets a clean slate. + """ + yield + trace._TRACER_PROVIDER_SET_ONCE._done = False + trace._TRACER_PROVIDER = trace.ProxyTracerProvider() diff --git a/tests/instrumentation/opentelemetry/_internal/test_configuration.py b/tests/instrumentation/opentelemetry/_internal/test_configuration.py new file mode 100644 index 0000000..14af268 --- /dev/null +++ b/tests/instrumentation/opentelemetry/_internal/test_configuration.py @@ -0,0 +1,111 @@ +"""Tests for the OpenTelemetry configuration module.""" + +import pytest + +from cloud_pipelines_backend.instrumentation.opentelemetry._internal import configuration + + +class TestExporterProtocol: + """Tests for configuration.ExporterProtocol enum.""" + + def test_grpc_value(self): + assert configuration.ExporterProtocol.GRPC == "grpc" + + def test_http_value(self): + assert configuration.ExporterProtocol.HTTP == "http" + + def test_invalid_value_raises(self): + with pytest.raises(ValueError): + configuration.ExporterProtocol("websocket") + + +class TestResolve: + """Tests for configuration.resolve().""" + + def test_returns_none_when_endpoint_not_set(self, monkeypatch): + monkeypatch.delenv("TANGLE_OTEL_EXPORTER_ENDPOINT", raising=False) + + result = configuration.resolve() + + assert result is None + + def test_returns_config_with_defaults(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + monkeypatch.delenv("TANGLE_OTEL_EXPORTER_PROTOCOL", raising=False) + monkeypatch.delenv("TANGLE_ENV", raising=False) + + result = configuration.resolve() + + assert result is not None + assert result.endpoint == "http://localhost:4317" + assert result.protocol == configuration.ExporterProtocol.GRPC + assert result.service_name == "tangle-unknown" + + def test_uses_custom_service_name(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + + result = configuration.resolve(service_name="my-api") + + assert result.service_name == "my-api" + + def test_service_name_includes_tangle_env(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + monkeypatch.setenv("TANGLE_ENV", "production") + + result = configuration.resolve() + + assert result.service_name == "tangle-production" + + def test_respects_http_protocol(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4318") + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_PROTOCOL", "http") + + result = configuration.resolve() + + assert result.protocol == configuration.ExporterProtocol.HTTP + + def test_respects_grpc_protocol(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_PROTOCOL", "grpc") + + result = configuration.resolve() + + assert result.protocol == configuration.ExporterProtocol.GRPC + + def test_raises_on_invalid_endpoint_format(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "localhost:4317") + + with pytest.raises(ValueError, match="Invalid OTel endpoint format"): + configuration.resolve() + + def test_raises_on_invalid_protocol(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_PROTOCOL", "websocket") + + with pytest.raises(ValueError, match="Invalid OTel protocol"): + configuration.resolve() + + def test_accepts_https_endpoint(self, monkeypatch): + monkeypatch.setenv( + "TANGLE_OTEL_EXPORTER_ENDPOINT", "https://collector.example.com:4317" + ) + + result = configuration.resolve() + + assert result.endpoint == "https://collector.example.com:4317" + + def test_config_is_frozen(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + + result = configuration.resolve() + + with pytest.raises(AttributeError): + result.endpoint = "http://other:4317" + + def test_config_requires_keyword_arguments(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + + result = configuration.resolve() + + with pytest.raises(TypeError): + configuration.OtelConfig(result.endpoint, result.protocol, result.service_name) diff --git a/tests/instrumentation/opentelemetry/_internal/test_providers.py b/tests/instrumentation/opentelemetry/_internal/test_providers.py new file mode 100644 index 0000000..74f450d --- /dev/null +++ b/tests/instrumentation/opentelemetry/_internal/test_providers.py @@ -0,0 +1,18 @@ +"""Tests for the OpenTelemetry provider state checks.""" + +from opentelemetry import trace +from opentelemetry.sdk import trace as otel_sdk_trace + +from cloud_pipelines_backend.instrumentation.opentelemetry._internal import providers + + +class TestHasConfiguredProviders: + """Tests for providers.has_configured_providers().""" + + def test_returns_false_with_default_provider(self): + assert providers.has_configured_providers() is False + + def test_returns_true_with_sdk_tracer_provider(self): + trace.set_tracer_provider(otel_sdk_trace.TracerProvider()) + + assert providers.has_configured_providers() is True diff --git a/tests/instrumentation/opentelemetry/conftest.py b/tests/instrumentation/opentelemetry/conftest.py new file mode 100644 index 0000000..8576f23 --- /dev/null +++ b/tests/instrumentation/opentelemetry/conftest.py @@ -0,0 +1,14 @@ +import pytest +from opentelemetry import trace + + +@pytest.fixture(autouse=True) +def reset_otel_tracer_provider(): + """Reset the global OTel tracer provider between tests. + + OTel only allows set_tracer_provider to be called once per process. + We reset the internal guard so each test gets a clean slate. + """ + yield + trace._TRACER_PROVIDER_SET_ONCE._done = False + trace._TRACER_PROVIDER = trace.ProxyTracerProvider() diff --git a/tests/instrumentation/opentelemetry/test_auto_instrumentation.py b/tests/instrumentation/opentelemetry/test_auto_instrumentation.py new file mode 100644 index 0000000..86e2083 --- /dev/null +++ b/tests/instrumentation/opentelemetry/test_auto_instrumentation.py @@ -0,0 +1,80 @@ +"""Tests for the OpenTelemetry auto-instrumentation module.""" + +from unittest import mock + +import fastapi +from opentelemetry import trace +from opentelemetry.sdk import trace as otel_sdk_trace + +from cloud_pipelines_backend.instrumentation.opentelemetry import auto_instrumentation + + +class TestInstrumentFastapi: + """Tests for auto_instrumentation.instrument_fastapi().""" + + def test_skips_when_no_providers_configured(self): + app = fastapi.FastAPI() + + with ( + mock.patch( + "cloud_pipelines_backend.instrumentation.opentelemetry._internal.providers.has_configured_providers", + return_value=False, + ) as mock_check, + mock.patch( + "opentelemetry.instrumentation.fastapi.FastAPIInstrumentor.instrument_app" + ) as mock_instrument, + ): + auto_instrumentation.instrument_fastapi(app) + + mock_check.assert_called_once() + mock_instrument.assert_not_called() + + def test_instruments_when_providers_configured(self): + app = fastapi.FastAPI() + + with ( + mock.patch( + "cloud_pipelines_backend.instrumentation.opentelemetry._internal.providers.has_configured_providers", + return_value=True, + ), + mock.patch( + "opentelemetry.instrumentation.fastapi.FastAPIInstrumentor.instrument_app" + ) as mock_instrument, + ): + auto_instrumentation.instrument_fastapi(app) + + mock_instrument.assert_called_once_with(app) + + def test_catches_instrumentation_exception(self): + app = fastapi.FastAPI() + + with ( + mock.patch( + "cloud_pipelines_backend.instrumentation.opentelemetry._internal.providers.has_configured_providers", + return_value=True, + ), + mock.patch( + "opentelemetry.instrumentation.fastapi.FastAPIInstrumentor.instrument_app", + side_effect=RuntimeError("instrumentation failed"), + ), + ): + auto_instrumentation.instrument_fastapi(app) + + +class TestInstrumentFastapiIntegration: + """Integration tests for auto_instrumentation.instrument_fastapi().""" + + def test_instruments_app_with_real_provider(self): + trace.set_tracer_provider(otel_sdk_trace.TracerProvider()) + app = fastapi.FastAPI() + + auto_instrumentation.instrument_fastapi(app) + + assert getattr(app, "_is_instrumented_by_opentelemetry", False) is True + + def test_skips_with_no_real_provider(self): + app = fastapi.FastAPI() + + auto_instrumentation.instrument_fastapi(app) + + assert getattr(app, "_is_instrumented_by_opentelemetry", False) is False diff --git a/tests/instrumentation/opentelemetry/test_providers.py b/tests/instrumentation/opentelemetry/test_providers.py new file mode 100644 index 0000000..ab188d6 --- /dev/null +++ b/tests/instrumentation/opentelemetry/test_providers.py @@ -0,0 +1,57 @@ +"""Tests for the OpenTelemetry providers setup module.""" + +from unittest import mock + +from opentelemetry import trace +from opentelemetry.sdk import trace as otel_sdk_trace + +from cloud_pipelines_backend.instrumentation.opentelemetry import providers + + +class TestProvidersSetup: + """Tests for providers.setup().""" + + def test_noop_when_endpoint_not_set(self, monkeypatch): + monkeypatch.delenv("TANGLE_OTEL_EXPORTER_ENDPOINT", raising=False) + + providers.setup() + + assert not isinstance( + trace.get_tracer_provider(), otel_sdk_trace.TracerProvider + ) + + def test_configures_tracer_provider(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + monkeypatch.delenv("TANGLE_OTEL_EXPORTER_PROTOCOL", raising=False) + + providers.setup() + + assert isinstance(trace.get_tracer_provider(), otel_sdk_trace.TracerProvider) + + def test_passes_custom_service_name(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "http://localhost:4317") + + providers.setup(service_name="my-orchestrator") + + provider = trace.get_tracer_provider() + assert provider.resource.attributes["service.name"] == "my-orchestrator" + + def test_catches_validation_errors(self, monkeypatch): + monkeypatch.setenv("TANGLE_OTEL_EXPORTER_ENDPOINT", "bad-endpoint") + + providers.setup() + + assert not isinstance( + trace.get_tracer_provider(), otel_sdk_trace.TracerProvider + ) + + def test_catches_config_resolution_errors(self): + with mock.patch( + "cloud_pipelines_backend.instrumentation.opentelemetry._internal.configuration.resolve", + side_effect=RuntimeError("unexpected"), + ): + providers.setup() + + assert not isinstance( + trace.get_tracer_provider(), otel_sdk_trace.TracerProvider + ) diff --git a/tests/instrumentation/opentelemetry/test_tracing.py b/tests/instrumentation/opentelemetry/test_tracing.py new file mode 100644 index 0000000..ef3f2a9 --- /dev/null +++ b/tests/instrumentation/opentelemetry/test_tracing.py @@ -0,0 +1,58 @@ +"""Tests for the OpenTelemetry tracing module.""" + +from unittest import mock + +from opentelemetry import trace +from opentelemetry.sdk import trace as otel_sdk_trace + +from cloud_pipelines_backend.instrumentation.opentelemetry._internal import configuration +from cloud_pipelines_backend.instrumentation.opentelemetry import tracing + + +class TestTracingSetup: + """Tests for tracing.setup().""" + + def test_sets_global_tracer_provider_with_grpc(self): + tracing.setup( + endpoint="http://localhost:4317", + protocol=configuration.ExporterProtocol.GRPC, + service_name="test-service", + ) + + provider = trace.get_tracer_provider() + assert isinstance(provider, otel_sdk_trace.TracerProvider) + + def test_sets_global_tracer_provider_with_http(self): + tracing.setup( + endpoint="http://localhost:4318", + protocol=configuration.ExporterProtocol.HTTP, + service_name="test-service", + ) + + provider = trace.get_tracer_provider() + assert isinstance(provider, otel_sdk_trace.TracerProvider) + + def test_service_name_is_set_on_resource(self): + tracing.setup( + endpoint="http://localhost:4317", + protocol=configuration.ExporterProtocol.GRPC, + service_name="my-service", + ) + + provider = trace.get_tracer_provider() + assert provider.resource.attributes["service.name"] == "my-service" + + def test_catches_exporter_exception(self): + with mock.patch( + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter.OTLPSpanExporter", + side_effect=RuntimeError("connection failed"), + ): + tracing.setup( + endpoint="http://localhost:4317", + protocol=configuration.ExporterProtocol.GRPC, + service_name="test-service", + ) + + assert not isinstance( + trace.get_tracer_provider(), otel_sdk_trace.TracerProvider + )