diff --git a/docs/devel_doc/openapi.json b/docs/devel_doc/openapi.json index 50defb330..ba3faa055 100644 --- a/docs/devel_doc/openapi.json +++ b/docs/devel_doc/openapi.json @@ -6753,6 +6753,15 @@ } ], "name": "lightspeed-stack", + "observability": { + "otel": { + "OTEL_EXPORTER_OTLP_ENDPOINT": "", + "OTEL_EXPORTER_OTLP_HEADERS": "[REDACTED]", + "OTEL_EXPORTER_OTLP_PROTOCOL": "", + "OTEL_SDK_DISABLED": "true", + "OTEL_SERVICE_NAME": "" + } + }, "quota_handlers": { "enable_token_history": false, "limiters": [], @@ -12367,6 +12376,11 @@ "title": "Splunk configuration", "description": "Splunk HEC configuration for sending telemetry events." }, + "observability": { + "$ref": "#/components/schemas/ObservabilityConfiguration", + "title": "Observability configuration", + "description": "OpenTelemetry and observability configuration collected from OTEL_* environment variables." + }, "deployment_environment": { "type": "string", "title": "Deployment environment", @@ -12463,6 +12477,15 @@ } ], "name": "lightspeed-stack", + "observability": { + "otel": { + "OTEL_EXPORTER_OTLP_ENDPOINT": "", + "OTEL_EXPORTER_OTLP_HEADERS": "[REDACTED]", + "OTEL_EXPORTER_OTLP_PROTOCOL": "", + "OTEL_SDK_DISABLED": "true", + "OTEL_SERVICE_NAME": "" + } + }, "quota_handlers": { "enable_token_history": false, "limiters": [], @@ -15107,6 +15130,22 @@ "title": "OAuthFlows", "description": "Defines the configuration for the supported OAuth 2.0 flows." }, + "ObservabilityConfiguration": { + "properties": { + "otel": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "OpenTelemetry configuration", + "description": "Active OpenTelemetry configuration from OTEL_* environment variables" + } + }, + "additionalProperties": false, + "type": "object", + "title": "ObservabilityConfiguration", + "description": "OpenTelemetry observability configuration.\n\nThis configuration is automatically populated from OTEL_* environment variables\nto provide visibility into the active tracing setup.\n\nAttributes:\n otel: Dictionary of OTEL_* environment variables with secrets redacted." + }, "OkpConfiguration": { "properties": { "rhokp_url": { diff --git a/src/models/api/responses/successful/configuration.py b/src/models/api/responses/successful/configuration.py index d41e8ff20..1d1c22d12 100644 --- a/src/models/api/responses/successful/configuration.py +++ b/src/models/api/responses/successful/configuration.py @@ -87,6 +87,15 @@ class ConfigurationResponse(AbstractSuccessfulResponse): "scheduler": {"period": 1}, "enable_token_history": False, }, + "observability": { + "otel": { + "OTEL_SDK_DISABLED": "true", + "OTEL_EXPORTER_OTLP_ENDPOINT": "", + "OTEL_EXPORTER_OTLP_PROTOCOL": "", + "OTEL_SERVICE_NAME": "", + "OTEL_EXPORTER_OTLP_HEADERS": "[REDACTED]", + } + }, } } ] diff --git a/src/models/config.py b/src/models/config.py index f609e7754..cd378fecf 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -2,6 +2,7 @@ # pylint: disable=too-many-lines +import os import re from enum import Enum from functools import cached_property @@ -2830,6 +2831,83 @@ def compiled_patterns(self) -> CompiledPatterns: return list(self._compiled_patterns) +class ObservabilityConfiguration(ConfigurationBase): + """OpenTelemetry observability configuration. + + This configuration is automatically populated from OTEL_* environment variables + to provide visibility into the active tracing setup. + + Attributes: + otel: Dictionary of OTEL_* environment variables with secrets redacted. + """ + + otel: dict[str, str] = Field( + default_factory=dict, + title="OpenTelemetry configuration", + description="Active OpenTelemetry configuration from OTEL_* environment variables", + ) + + @field_validator("otel", mode="before") + @classmethod + def redact_secrets(cls, value: dict[str, str]) -> dict[str, str]: + """Redact sensitive OTEL environment variables. + + Redacts headers, certificates, and client keys for generic and signal-specific + (traces, metrics, logs) OTLP exporters. + + Parameters: + ---------- + value: Dictionary of OTEL environment variables + + Returns: + New dictionary with sensitive values redacted as "[REDACTED]" + """ + if not value: + return value + + # Create a new dict to avoid mutating caller's data + redacted = {} + + for key, val in value.items(): + # Redact generic and signal-specific OTLP headers + # Matches: OTEL_EXPORTER_OTLP_HEADERS, + # OTEL_EXPORTER_OTLP_TRACES_HEADERS, + # OTEL_EXPORTER_OTLP_METRICS_HEADERS, + # OTEL_EXPORTER_OTLP_LOGS_HEADERS + if "HEADERS" in key and "OTLP" in key: + redacted[key] = "[REDACTED]" + # Redact generic and signal-specific certificates + # Matches: OTEL_EXPORTER_OTLP_CERTIFICATE, + # OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, etc. + elif "CERTIFICATE" in key and "OTLP" in key: + redacted[key] = "[REDACTED]" + # Redact generic and signal-specific client keys + # Matches: OTEL_EXPORTER_OTLP_CLIENT_KEY, + # OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, etc. + elif "CLIENT_KEY" in key and "OTLP" in key: + redacted[key] = "[REDACTED]" + else: + redacted[key] = val + + return redacted + + @classmethod + def from_environment(cls) -> "ObservabilityConfiguration": + """Collect all OTEL_* environment variables from the environment. + + Sensitive variables (headers, certificates, keys) are automatically redacted + by the field validator. + + Returns: + ObservabilityConfiguration with otel dict populated from environment. + """ + otel_vars = {} + for key, value in os.environ.items(): + if key.startswith("OTEL_"): + otel_vars[key] = value + return cls(otel=otel_vars) + + class Configuration(ConfigurationBase): """Global service configuration.""" @@ -2991,6 +3069,13 @@ class Configuration(ConfigurationBase): description="Splunk HEC configuration for sending telemetry events.", ) + observability: ObservabilityConfiguration = Field( + default_factory=ObservabilityConfiguration.from_environment, + title="Observability configuration", + description="OpenTelemetry and observability configuration collected " + "from OTEL_* environment variables.", + ) + deployment_environment: str = Field( "development", title="Deployment environment", diff --git a/tests/integration/endpoints/test_config_integration.py b/tests/integration/endpoints/test_config_integration.py index 1df9a5e02..b06301df5 100644 --- a/tests/integration/endpoints/test_config_integration.py +++ b/tests/integration/endpoints/test_config_integration.py @@ -88,3 +88,85 @@ async def test_config_endpoint_fails_without_configuration( assert "response" in exc_info.value.detail detail = cast(dict[str, str], exc_info.value.detail) assert "configuration is not loaded" in detail["response"].lower() + + +@pytest.mark.asyncio +async def test_config_endpoint_includes_observability( + current_config: AppConfig, # pylint: disable=unused-argument + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test that config endpoint includes observability configuration. + + This integration test verifies: + - Observability field is present in the configuration response + - OTEL environment variables are correctly collected + - Response structure includes observability.otel block + + Parameters: + ---------- + current_config (AppConfig): Loads root configuration + test_request (Request): FastAPI request + test_auth (AuthTuple): noop authentication tuple + """ + response = await config_endpoint_handler(auth=test_auth, request=test_request) + + # Verify observability field exists + assert hasattr(response.configuration, "observability") + assert response.configuration.observability is not None + + # Verify otel field exists + assert hasattr(response.configuration.observability, "otel") + assert isinstance(response.configuration.observability.otel, dict) + + +@pytest.mark.asyncio +async def test_config_endpoint_observability_collects_otel_vars( + current_config: AppConfig, + test_request: Request, + test_auth: AuthTuple, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that observability config collects OTEL_* environment variables. + + This integration test verifies the full endpoint integration: + - OTEL_* environment variables are collected into observability.otel + - Configuration reload picks up new environment variables + - Endpoint response includes the updated observability configuration + + Parameters: + ---------- + current_config (AppConfig): Loads root configuration + test_request (Request): FastAPI request + test_auth (AuthTuple): noop authentication tuple + monkeypatch (pytest.MonkeyPatch): Fixture to modify environment variables + """ + # pylint: disable=import-outside-toplevel + from pathlib import Path + + # Set OTEL environment variables + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + monkeypatch.setenv("OTEL_SERVICE_NAME", "test-service") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + + # Reload configuration to pick up new env vars + # The observability field is populated via from_environment() during config load + config_path = Path(__file__).parent.parent.parent.parent / "lightspeed-stack.yaml" + current_config.load_configuration(str(config_path)) + + # Call the actual endpoint handler to test full integration + response = await config_endpoint_handler(auth=test_auth, request=test_request) + + # Verify that the endpoint response includes observability configuration + assert hasattr(response.configuration, "observability") + assert response.configuration.observability is not None + assert hasattr(response.configuration.observability, "otel") + + # Verify OTEL vars are present in the endpoint response + otel_config = response.configuration.observability.otel + assert "OTEL_SDK_DISABLED" in otel_config + assert otel_config["OTEL_SDK_DISABLED"] == "true" + assert "OTEL_SERVICE_NAME" in otel_config + assert otel_config["OTEL_SERVICE_NAME"] == "test-service" + assert "OTEL_EXPORTER_OTLP_ENDPOINT" in otel_config + assert otel_config["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:4317" diff --git a/tests/unit/models/config/test_dump_configuration.py b/tests/unit/models/config/test_dump_configuration.py index 5aae2db4b..56970e82b 100644 --- a/tests/unit/models/config/test_dump_configuration.py +++ b/tests/unit/models/config/test_dump_configuration.py @@ -4,9 +4,11 @@ # pyright: reportCallIssue=false import json +import os from pathlib import Path from typing import Any +import pytest from pydantic import SecretStr import constants @@ -52,6 +54,26 @@ } +@pytest.fixture(name="clear_otel_env", autouse=True) +def clear_otel_env_fixture(monkeypatch: pytest.MonkeyPatch) -> None: + """Clear OTEL_* environment variables to prevent ambient leakage in tests. + + This fixture ensures dump configuration tests have stable expectations + regardless of whether they run on instrumented CI runners. + """ + for key in list(os.environ.keys()): + if key.startswith("OTEL_"): + monkeypatch.delenv(key, raising=False) + + +def _get_expected_observability_dump() -> dict[str, dict[str, str]]: + """Get expected observability dump based on current environment. + + Returns empty otel dict when OTEL_* vars are cleared by fixture. + """ + return {"otel": {}} + + def test_dump_configuration_minimal_cfg(tmp_path: Path) -> None: """ Test that the Configuration object can be serialized to a JSON file and @@ -232,6 +254,7 @@ def test_dump_configuration_minimal_cfg(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -459,6 +482,7 @@ def test_dump_configuration_valid_values(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -837,6 +861,7 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -1099,6 +1124,7 @@ def test_dump_configuration_with_quota_limiters_different_values( "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -1394,6 +1420,7 @@ def test_dump_configuration_byok(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -1616,6 +1643,7 @@ def test_dump_configuration_pg_namespace(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -1998,6 +2026,7 @@ def test_dump_configuration_allow_degraded_mode(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -2226,6 +2255,7 @@ def test_dump_configuration_max_retries_settings(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -2454,6 +2484,7 @@ def test_dump_configuration_retry_count_settings(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, @@ -2689,6 +2720,7 @@ def test_dump_configuration_specific_compaction_values(tmp_path: Path) -> None: "quota_subject": None, }, "splunk": None, + "observability": _get_expected_observability_dump(), "deployment_environment": "development", "reranker": { "enabled": False, diff --git a/tests/unit/models/config/test_observability_configuration.py b/tests/unit/models/config/test_observability_configuration.py new file mode 100644 index 000000000..75ebc1dc9 --- /dev/null +++ b/tests/unit/models/config/test_observability_configuration.py @@ -0,0 +1,313 @@ +"""Unit tests for ObservabilityConfiguration model.""" + +import os + +import pytest + +from models.config import ObservabilityConfiguration + + +def test_default_values() -> None: + """Test default ObservabilityConfiguration has expected values.""" + cfg = ObservabilityConfiguration() + assert cfg.otel == {} + + +def test_from_environment_no_otel_vars(monkeypatch: pytest.MonkeyPatch) -> None: + """Test from_environment with no OTEL_* environment variables. + + Parameters: + ---------- + monkeypatch (pytest.MonkeyPatch): Pytest fixture for environment manipulation. + """ + # Clear any existing OTEL_ variables + for key in list(os.environ.keys()): + if key.startswith("OTEL_"): + monkeypatch.delenv(key, raising=False) + + cfg = ObservabilityConfiguration.from_environment() + assert cfg.otel == {} + + +def test_from_environment_with_otel_vars(monkeypatch: pytest.MonkeyPatch) -> None: + """Test from_environment collects OTEL_* environment variables. + + Parameters: + ---------- + monkeypatch (pytest.MonkeyPatch): Pytest fixture for environment manipulation. + """ + # Set OTEL_ environment variables + otel_vars = { + "OTEL_SDK_DISABLED": "true", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + "OTEL_SERVICE_NAME": "lightspeed-stack", + } + + for key, value in otel_vars.items(): + monkeypatch.setenv(key, value) + + cfg = ObservabilityConfiguration.from_environment() + + # Verify all OTEL_ vars are collected + for key, value in otel_vars.items(): + assert key in cfg.otel + assert cfg.otel[key] == value + + +def test_from_environment_ignores_non_otel_vars( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test from_environment only collects OTEL_* variables. + + Parameters: + ---------- + monkeypatch (pytest.MonkeyPatch): Pytest fixture for environment manipulation. + """ + # Set some non-OTEL variables + monkeypatch.setenv("PATH", "/usr/bin") + monkeypatch.setenv("HOME", "/home/user") + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + + cfg = ObservabilityConfiguration.from_environment() + + # Only OTEL_ vars should be collected + assert "PATH" not in cfg.otel + assert "HOME" not in cfg.otel + assert "OTEL_SDK_DISABLED" in cfg.otel + assert cfg.otel["OTEL_SDK_DISABLED"] == "true" + + +def test_manual_construction() -> None: + """Test manual construction of ObservabilityConfiguration.""" + otel_dict = { + "OTEL_SDK_DISABLED": "false", + "OTEL_SERVICE_NAME": "test-service", + } + + cfg = ObservabilityConfiguration(otel=otel_dict) + + assert cfg.otel == otel_dict + assert cfg.otel["OTEL_SDK_DISABLED"] == "false" + assert cfg.otel["OTEL_SERVICE_NAME"] == "test-service" + + +@pytest.mark.parametrize( + ("otel_dict", "expected_count"), + [ + ({}, 0), + ({"OTEL_SDK_DISABLED": "true"}, 1), + ( + { + "OTEL_SDK_DISABLED": "true", + "OTEL_SERVICE_NAME": "test", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + }, + 3, + ), + ], + ids=[ + "empty", + "single_var", + "multiple_vars", + ], +) +def test_otel_dict_sizes(otel_dict: dict[str, str], expected_count: int) -> None: + """Test ObservabilityConfiguration with various otel dict sizes. + + Parameters: + ---------- + otel_dict (dict[str, str]): Dictionary of OTEL environment variables to test. + expected_count (int): Expected number of items in the resulting otel dict. + """ + cfg = ObservabilityConfiguration(otel=otel_dict) + assert len(cfg.otel) == expected_count + + +def test_otel_empty_string_values() -> None: + """Test ObservabilityConfiguration handles empty string values.""" + otel_dict = { + "OTEL_EXPORTER_OTLP_ENDPOINT": "", + "OTEL_SERVICE_NAME": "", + } + + cfg = ObservabilityConfiguration(otel=otel_dict) + + assert cfg.otel["OTEL_EXPORTER_OTLP_ENDPOINT"] == "" + assert cfg.otel["OTEL_SERVICE_NAME"] == "" + + +def test_model_config_extra_forbid() -> None: + """Test that extra fields are forbidden (inherited from ConfigurationBase).""" + with pytest.raises(ValueError, match="Extra inputs are not permitted"): + ObservabilityConfiguration( + otel={}, + unexpected_field="value", # type: ignore[call-arg] + ) + + +def test_from_environment_redacts_secret_headers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that OTEL_EXPORTER_OTLP_HEADERS is redacted. + + Parameters: + ---------- + monkeypatch (pytest.MonkeyPatch): Pytest fixture for environment manipulation. + """ + monkeypatch.setenv( + "OTEL_EXPORTER_OTLP_HEADERS", "Authorization=Bearer secret_token" + ) + monkeypatch.setenv("OTEL_SERVICE_NAME", "test-service") + + cfg = ObservabilityConfiguration.from_environment() + + # Secret headers should be redacted + assert "OTEL_EXPORTER_OTLP_HEADERS" in cfg.otel + assert cfg.otel["OTEL_EXPORTER_OTLP_HEADERS"] == "[REDACTED]" + + # Non-secret vars should not be redacted + assert "OTEL_SERVICE_NAME" in cfg.otel + assert cfg.otel["OTEL_SERVICE_NAME"] == "test-service" + + +def test_from_environment_redacts_mtls_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that mTLS certificate and key paths are redacted. + + Parameters: + ---------- + monkeypatch (pytest.MonkeyPatch): Pytest fixture for environment manipulation. + """ + monkeypatch.setenv("OTEL_EXPORTER_OTLP_CERTIFICATE", "/path/to/cert.pem") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_CLIENT_KEY", "/path/to/key.pem") + monkeypatch.setenv("OTEL_SDK_DISABLED", "false") + + cfg = ObservabilityConfiguration.from_environment() + + # All mTLS-related vars should be redacted + assert cfg.otel["OTEL_EXPORTER_OTLP_CERTIFICATE"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_CLIENT_KEY"] == "[REDACTED]" + + # Non-secret var should not be redacted + assert cfg.otel["OTEL_SDK_DISABLED"] == "false" + + +def test_from_environment_redacts_all_secret_vars( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that all secret OTEL variables are redacted. + + Parameters: + ---------- + monkeypatch (pytest.MonkeyPatch): Pytest fixture for environment manipulation. + """ + secret_vars = { + "OTEL_EXPORTER_OTLP_HEADERS": "secret-headers", + "OTEL_EXPORTER_OTLP_CERTIFICATE": "/secret/cert.pem", + "OTEL_EXPORTER_OTLP_CLIENT_KEY": "/secret/key.pem", + } + + # Set all secret vars + for key, value in secret_vars.items(): + monkeypatch.setenv(key, value) + + # Also set some non-secret vars + monkeypatch.setenv("OTEL_SERVICE_NAME", "test-service") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + + cfg = ObservabilityConfiguration.from_environment() + + # All secret vars should be redacted + for key in secret_vars: + assert key in cfg.otel + assert cfg.otel[key] == "[REDACTED]" + + # Non-secret vars should not be redacted + assert cfg.otel["OTEL_SERVICE_NAME"] == "test-service" + assert cfg.otel["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:4317" + + +def test_direct_construction_redacts_secrets() -> None: + """Test that direct construction also redacts secrets via validator.""" + otel_dict = { + "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer secret", + "OTEL_EXPORTER_OTLP_CERTIFICATE": "/path/to/cert.pem", + "OTEL_EXPORTER_OTLP_CLIENT_KEY": "/path/to/key.pem", + "OTEL_SERVICE_NAME": "my-service", + "OTEL_SDK_DISABLED": "false", + } + + cfg = ObservabilityConfiguration(otel=otel_dict) + + # Secrets should be redacted + assert cfg.otel["OTEL_EXPORTER_OTLP_HEADERS"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_CERTIFICATE"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_CLIENT_KEY"] == "[REDACTED]" + + # Non-secrets should not be redacted + assert cfg.otel["OTEL_SERVICE_NAME"] == "my-service" + assert cfg.otel["OTEL_SDK_DISABLED"] == "false" + + +def test_signal_specific_headers_redacted() -> None: + """Test that signal-specific OTLP headers are redacted.""" + otel_dict = { + "OTEL_EXPORTER_OTLP_TRACES_HEADERS": "Authorization=Bearer trace-token", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS": "Authorization=Bearer metrics-token", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS": "Authorization=Bearer logs-token", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4317", + } + + cfg = ObservabilityConfiguration(otel=otel_dict) + + # All signal-specific headers should be redacted + assert cfg.otel["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_METRICS_HEADERS"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_LOGS_HEADERS"] == "[REDACTED]" + + # Non-secret should not be redacted + assert cfg.otel["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://collector:4317" + + +def test_signal_specific_certificates_redacted() -> None: + """Test that signal-specific OTLP certificates and keys are redacted.""" + otel_dict = { + "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE": "/path/to/traces-cert.pem", + "OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE": "/path/to/metrics-cert.pem", + "OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY": "/path/to/traces-key.pem", + "OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY": "/path/to/metrics-key.pem", + "OTEL_SERVICE_NAME": "test-service", + } + + cfg = ObservabilityConfiguration(otel=otel_dict) + + # All signal-specific certificates and keys should be redacted + assert cfg.otel["OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY"] == "[REDACTED]" + assert cfg.otel["OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY"] == "[REDACTED]" + + # Non-secret should not be redacted + assert cfg.otel["OTEL_SERVICE_NAME"] == "test-service" + + +def test_validator_does_not_mutate_input() -> None: + """Test that the validator returns a new dict without mutating input.""" + original_dict = { + "OTEL_EXPORTER_OTLP_HEADERS": "secret-token", + "OTEL_SERVICE_NAME": "test-service", + } + + # Create a copy to verify original isn't mutated + original_copy = original_dict.copy() + + cfg = ObservabilityConfiguration(otel=original_dict) + + # Original dict should be unchanged + assert original_dict == original_copy + assert original_dict["OTEL_EXPORTER_OTLP_HEADERS"] == "secret-token" + + # But the config should have redacted value + assert cfg.otel["OTEL_EXPORTER_OTLP_HEADERS"] == "[REDACTED]"