Skip to content

Commit 6acd7f4

Browse files
committed
LCORE-1826: Expose OpenTelemetry configuration in v1/config endpoint
Add observability configuration to the v1/config endpoint, exposing all OTEL_* environment variables to provide visibility into the active OpenTelemetry tracing setup. Signed-off-by: Anik Bhattacharjee <anbhatta@redhat.com>
1 parent 83ba37b commit 6acd7f4

6 files changed

Lines changed: 560 additions & 0 deletions

File tree

docs/devel_doc/openapi.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6753,6 +6753,15 @@
67536753
}
67546754
],
67556755
"name": "lightspeed-stack",
6756+
"observability": {
6757+
"otel": {
6758+
"OTEL_EXPORTER_OTLP_ENDPOINT": "",
6759+
"OTEL_EXPORTER_OTLP_HEADERS": "[REDACTED]",
6760+
"OTEL_EXPORTER_OTLP_PROTOCOL": "",
6761+
"OTEL_SDK_DISABLED": "true",
6762+
"OTEL_SERVICE_NAME": ""
6763+
}
6764+
},
67566765
"quota_handlers": {
67576766
"enable_token_history": false,
67586767
"limiters": [],
@@ -12367,6 +12376,11 @@
1236712376
"title": "Splunk configuration",
1236812377
"description": "Splunk HEC configuration for sending telemetry events."
1236912378
},
12379+
"observability": {
12380+
"$ref": "#/components/schemas/ObservabilityConfiguration",
12381+
"title": "Observability configuration",
12382+
"description": "OpenTelemetry and observability configuration collected from OTEL_* environment variables."
12383+
},
1237012384
"deployment_environment": {
1237112385
"type": "string",
1237212386
"title": "Deployment environment",
@@ -12463,6 +12477,15 @@
1246312477
}
1246412478
],
1246512479
"name": "lightspeed-stack",
12480+
"observability": {
12481+
"otel": {
12482+
"OTEL_EXPORTER_OTLP_ENDPOINT": "",
12483+
"OTEL_EXPORTER_OTLP_HEADERS": "[REDACTED]",
12484+
"OTEL_EXPORTER_OTLP_PROTOCOL": "",
12485+
"OTEL_SDK_DISABLED": "true",
12486+
"OTEL_SERVICE_NAME": ""
12487+
}
12488+
},
1246612489
"quota_handlers": {
1246712490
"enable_token_history": false,
1246812491
"limiters": [],
@@ -15107,6 +15130,22 @@
1510715130
"title": "OAuthFlows",
1510815131
"description": "Defines the configuration for the supported OAuth 2.0 flows."
1510915132
},
15133+
"ObservabilityConfiguration": {
15134+
"properties": {
15135+
"otel": {
15136+
"additionalProperties": {
15137+
"type": "string"
15138+
},
15139+
"type": "object",
15140+
"title": "OpenTelemetry configuration",
15141+
"description": "Active OpenTelemetry configuration from OTEL_* environment variables"
15142+
}
15143+
},
15144+
"additionalProperties": false,
15145+
"type": "object",
15146+
"title": "ObservabilityConfiguration",
15147+
"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."
15148+
},
1511015149
"OkpConfiguration": {
1511115150
"properties": {
1511215151
"rhokp_url": {

src/models/api/responses/successful/configuration.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@ class ConfigurationResponse(AbstractSuccessfulResponse):
8787
"scheduler": {"period": 1},
8888
"enable_token_history": False,
8989
},
90+
"observability": {
91+
"otel": {
92+
"OTEL_SDK_DISABLED": "true",
93+
"OTEL_EXPORTER_OTLP_ENDPOINT": "",
94+
"OTEL_EXPORTER_OTLP_PROTOCOL": "",
95+
"OTEL_SERVICE_NAME": "",
96+
"OTEL_EXPORTER_OTLP_HEADERS": "[REDACTED]",
97+
}
98+
},
9099
}
91100
}
92101
]

src/models/config.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# pylint: disable=too-many-lines
44

5+
import os
56
import re
67
from enum import Enum
78
from functools import cached_property
@@ -2830,6 +2831,83 @@ def compiled_patterns(self) -> CompiledPatterns:
28302831
return list(self._compiled_patterns)
28312832

28322833

2834+
class ObservabilityConfiguration(ConfigurationBase):
2835+
"""OpenTelemetry observability configuration.
2836+
2837+
This configuration is automatically populated from OTEL_* environment variables
2838+
to provide visibility into the active tracing setup.
2839+
2840+
Attributes:
2841+
otel: Dictionary of OTEL_* environment variables with secrets redacted.
2842+
"""
2843+
2844+
otel: dict[str, str] = Field(
2845+
default_factory=dict,
2846+
title="OpenTelemetry configuration",
2847+
description="Active OpenTelemetry configuration from OTEL_* environment variables",
2848+
)
2849+
2850+
@field_validator("otel", mode="before")
2851+
@classmethod
2852+
def redact_secrets(cls, value: dict[str, str]) -> dict[str, str]:
2853+
"""Redact sensitive OTEL environment variables.
2854+
2855+
Redacts headers, certificates, and client keys for generic and signal-specific
2856+
(traces, metrics, logs) OTLP exporters.
2857+
2858+
Parameters:
2859+
----------
2860+
value: Dictionary of OTEL environment variables
2861+
2862+
Returns:
2863+
New dictionary with sensitive values redacted as "[REDACTED]"
2864+
"""
2865+
if not value:
2866+
return value
2867+
2868+
# Create a new dict to avoid mutating caller's data
2869+
redacted = {}
2870+
2871+
for key, val in value.items():
2872+
# Redact generic and signal-specific OTLP headers
2873+
# Matches: OTEL_EXPORTER_OTLP_HEADERS,
2874+
# OTEL_EXPORTER_OTLP_TRACES_HEADERS,
2875+
# OTEL_EXPORTER_OTLP_METRICS_HEADERS,
2876+
# OTEL_EXPORTER_OTLP_LOGS_HEADERS
2877+
if "HEADERS" in key and "OTLP" in key:
2878+
redacted[key] = "[REDACTED]"
2879+
# Redact generic and signal-specific certificates
2880+
# Matches: OTEL_EXPORTER_OTLP_CERTIFICATE,
2881+
# OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, etc.
2882+
elif "CERTIFICATE" in key and "OTLP" in key:
2883+
redacted[key] = "[REDACTED]"
2884+
# Redact generic and signal-specific client keys
2885+
# Matches: OTEL_EXPORTER_OTLP_CLIENT_KEY,
2886+
# OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY, etc.
2887+
elif "CLIENT_KEY" in key and "OTLP" in key:
2888+
redacted[key] = "[REDACTED]"
2889+
else:
2890+
redacted[key] = val
2891+
2892+
return redacted
2893+
2894+
@classmethod
2895+
def from_environment(cls) -> "ObservabilityConfiguration":
2896+
"""Collect all OTEL_* environment variables from the environment.
2897+
2898+
Sensitive variables (headers, certificates, keys) are automatically redacted
2899+
by the field validator.
2900+
2901+
Returns:
2902+
ObservabilityConfiguration with otel dict populated from environment.
2903+
"""
2904+
otel_vars = {}
2905+
for key, value in os.environ.items():
2906+
if key.startswith("OTEL_"):
2907+
otel_vars[key] = value
2908+
return cls(otel=otel_vars)
2909+
2910+
28332911
class Configuration(ConfigurationBase):
28342912
"""Global service configuration."""
28352913

@@ -2991,6 +3069,13 @@ class Configuration(ConfigurationBase):
29913069
description="Splunk HEC configuration for sending telemetry events.",
29923070
)
29933071

3072+
observability: ObservabilityConfiguration = Field(
3073+
default_factory=ObservabilityConfiguration.from_environment,
3074+
title="Observability configuration",
3075+
description="OpenTelemetry and observability configuration collected "
3076+
"from OTEL_* environment variables.",
3077+
)
3078+
29943079
deployment_environment: str = Field(
29953080
"development",
29963081
title="Deployment environment",

tests/integration/endpoints/test_config_integration.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,85 @@ async def test_config_endpoint_fails_without_configuration(
8888
assert "response" in exc_info.value.detail
8989
detail = cast(dict[str, str], exc_info.value.detail)
9090
assert "configuration is not loaded" in detail["response"].lower()
91+
92+
93+
@pytest.mark.asyncio
94+
async def test_config_endpoint_includes_observability(
95+
current_config: AppConfig, # pylint: disable=unused-argument
96+
test_request: Request,
97+
test_auth: AuthTuple,
98+
) -> None:
99+
"""Test that config endpoint includes observability configuration.
100+
101+
This integration test verifies:
102+
- Observability field is present in the configuration response
103+
- OTEL environment variables are correctly collected
104+
- Response structure includes observability.otel block
105+
106+
Parameters:
107+
----------
108+
current_config (AppConfig): Loads root configuration
109+
test_request (Request): FastAPI request
110+
test_auth (AuthTuple): noop authentication tuple
111+
"""
112+
response = await config_endpoint_handler(auth=test_auth, request=test_request)
113+
114+
# Verify observability field exists
115+
assert hasattr(response.configuration, "observability")
116+
assert response.configuration.observability is not None
117+
118+
# Verify otel field exists
119+
assert hasattr(response.configuration.observability, "otel")
120+
assert isinstance(response.configuration.observability.otel, dict)
121+
122+
123+
@pytest.mark.asyncio
124+
async def test_config_endpoint_observability_collects_otel_vars(
125+
current_config: AppConfig,
126+
test_request: Request,
127+
test_auth: AuthTuple,
128+
monkeypatch: pytest.MonkeyPatch,
129+
) -> None:
130+
"""Test that observability config collects OTEL_* environment variables.
131+
132+
This integration test verifies the full endpoint integration:
133+
- OTEL_* environment variables are collected into observability.otel
134+
- Configuration reload picks up new environment variables
135+
- Endpoint response includes the updated observability configuration
136+
137+
Parameters:
138+
----------
139+
current_config (AppConfig): Loads root configuration
140+
test_request (Request): FastAPI request
141+
test_auth (AuthTuple): noop authentication tuple
142+
monkeypatch (pytest.MonkeyPatch): Fixture to modify environment variables
143+
"""
144+
# pylint: disable=import-outside-toplevel
145+
from pathlib import Path
146+
147+
# Set OTEL environment variables
148+
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
149+
monkeypatch.setenv("OTEL_SERVICE_NAME", "test-service")
150+
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
151+
152+
# Reload configuration to pick up new env vars
153+
# The observability field is populated via from_environment() during config load
154+
config_path = Path(__file__).parent.parent.parent.parent / "lightspeed-stack.yaml"
155+
current_config.load_configuration(str(config_path))
156+
157+
# Call the actual endpoint handler to test full integration
158+
response = await config_endpoint_handler(auth=test_auth, request=test_request)
159+
160+
# Verify that the endpoint response includes observability configuration
161+
assert hasattr(response.configuration, "observability")
162+
assert response.configuration.observability is not None
163+
assert hasattr(response.configuration.observability, "otel")
164+
165+
# Verify OTEL vars are present in the endpoint response
166+
otel_config = response.configuration.observability.otel
167+
assert "OTEL_SDK_DISABLED" in otel_config
168+
assert otel_config["OTEL_SDK_DISABLED"] == "true"
169+
assert "OTEL_SERVICE_NAME" in otel_config
170+
assert otel_config["OTEL_SERVICE_NAME"] == "test-service"
171+
assert "OTEL_EXPORTER_OTLP_ENDPOINT" in otel_config
172+
assert otel_config["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:4317"

tests/unit/models/config/test_dump_configuration.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
# pyright: reportCallIssue=false
55

66
import json
7+
import os
78
from pathlib import Path
89
from typing import Any
910

11+
import pytest
1012
from pydantic import SecretStr
1113

1214
import constants
@@ -52,6 +54,26 @@
5254
}
5355

5456

57+
@pytest.fixture(name="clear_otel_env", autouse=True)
58+
def clear_otel_env_fixture(monkeypatch: pytest.MonkeyPatch) -> None:
59+
"""Clear OTEL_* environment variables to prevent ambient leakage in tests.
60+
61+
This fixture ensures dump configuration tests have stable expectations
62+
regardless of whether they run on instrumented CI runners.
63+
"""
64+
for key in list(os.environ.keys()):
65+
if key.startswith("OTEL_"):
66+
monkeypatch.delenv(key, raising=False)
67+
68+
69+
def _get_expected_observability_dump() -> dict[str, dict[str, str]]:
70+
"""Get expected observability dump based on current environment.
71+
72+
Returns empty otel dict when OTEL_* vars are cleared by fixture.
73+
"""
74+
return {"otel": {}}
75+
76+
5577
def test_dump_configuration_minimal_cfg(tmp_path: Path) -> None:
5678
"""
5779
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:
232254
"quota_subject": None,
233255
},
234256
"splunk": None,
257+
"observability": _get_expected_observability_dump(),
235258
"deployment_environment": "development",
236259
"reranker": {
237260
"enabled": False,
@@ -459,6 +482,7 @@ def test_dump_configuration_valid_values(tmp_path: Path) -> None:
459482
"quota_subject": None,
460483
},
461484
"splunk": None,
485+
"observability": _get_expected_observability_dump(),
462486
"deployment_environment": "development",
463487
"reranker": {
464488
"enabled": False,
@@ -837,6 +861,7 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None:
837861
"quota_subject": None,
838862
},
839863
"splunk": None,
864+
"observability": _get_expected_observability_dump(),
840865
"deployment_environment": "development",
841866
"reranker": {
842867
"enabled": False,
@@ -1099,6 +1124,7 @@ def test_dump_configuration_with_quota_limiters_different_values(
10991124
"quota_subject": None,
11001125
},
11011126
"splunk": None,
1127+
"observability": _get_expected_observability_dump(),
11021128
"deployment_environment": "development",
11031129
"reranker": {
11041130
"enabled": False,
@@ -1394,6 +1420,7 @@ def test_dump_configuration_byok(tmp_path: Path) -> None:
13941420
"quota_subject": None,
13951421
},
13961422
"splunk": None,
1423+
"observability": _get_expected_observability_dump(),
13971424
"deployment_environment": "development",
13981425
"reranker": {
13991426
"enabled": False,
@@ -1616,6 +1643,7 @@ def test_dump_configuration_pg_namespace(tmp_path: Path) -> None:
16161643
"quota_subject": None,
16171644
},
16181645
"splunk": None,
1646+
"observability": _get_expected_observability_dump(),
16191647
"deployment_environment": "development",
16201648
"reranker": {
16211649
"enabled": False,
@@ -1998,6 +2026,7 @@ def test_dump_configuration_allow_degraded_mode(tmp_path: Path) -> None:
19982026
"quota_subject": None,
19992027
},
20002028
"splunk": None,
2029+
"observability": _get_expected_observability_dump(),
20012030
"deployment_environment": "development",
20022031
"reranker": {
20032032
"enabled": False,
@@ -2226,6 +2255,7 @@ def test_dump_configuration_max_retries_settings(tmp_path: Path) -> None:
22262255
"quota_subject": None,
22272256
},
22282257
"splunk": None,
2258+
"observability": _get_expected_observability_dump(),
22292259
"deployment_environment": "development",
22302260
"reranker": {
22312261
"enabled": False,
@@ -2454,6 +2484,7 @@ def test_dump_configuration_retry_count_settings(tmp_path: Path) -> None:
24542484
"quota_subject": None,
24552485
},
24562486
"splunk": None,
2487+
"observability": _get_expected_observability_dump(),
24572488
"deployment_environment": "development",
24582489
"reranker": {
24592490
"enabled": False,
@@ -2689,6 +2720,7 @@ def test_dump_configuration_specific_compaction_values(tmp_path: Path) -> None:
26892720
"quota_subject": None,
26902721
},
26912722
"splunk": None,
2723+
"observability": _get_expected_observability_dump(),
26922724
"deployment_environment": "development",
26932725
"reranker": {
26942726
"enabled": False,

0 commit comments

Comments
 (0)