Skip to content

Commit 4245a22

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 4245a22

6 files changed

Lines changed: 390 additions & 1 deletion

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: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
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
89
from pathlib import Path
910
from re import Pattern
10-
from typing import Annotated, Any, Literal, Optional, Self
11+
from typing import Annotated, Any, Final, Literal, Optional, Self
1112

1213
import jsonpath_ng
1314
import yaml
@@ -35,6 +36,14 @@
3536

3637
logger = get_logger(__name__)
3738

39+
# OTEL environment variables that contain sensitive data and should be redacted
40+
_SECRET_OTEL_VARS: Final[set[str]] = {
41+
"OTEL_EXPORTER_OTLP_HEADERS", # Can contain bearer tokens, API keys
42+
"OTEL_EXPORTER_OTLP_CERTIFICATE", # mTLS certificate path (sensitive)
43+
"OTEL_EXPORTER_OTLP_CLIENT_KEY", # mTLS client key path (sensitive)
44+
"OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", # mTLS client cert (sensitive)
45+
}
46+
3847

3948
class ConfigurationBase(BaseModel):
4049
"""Base class for all configuration models that rejects unknown fields."""
@@ -2830,6 +2839,42 @@ def compiled_patterns(self) -> CompiledPatterns:
28302839
return list(self._compiled_patterns)
28312840

28322841

2842+
class ObservabilityConfiguration(ConfigurationBase):
2843+
"""OpenTelemetry observability configuration.
2844+
2845+
This configuration is automatically populated from OTEL_* environment variables
2846+
to provide visibility into the active tracing setup.
2847+
2848+
Attributes:
2849+
otel: Dictionary of OTEL_* environment variables with secrets redacted.
2850+
"""
2851+
2852+
otel: dict[str, str] = Field(
2853+
default_factory=dict,
2854+
title="OpenTelemetry configuration",
2855+
description="Active OpenTelemetry configuration from OTEL_* environment variables",
2856+
)
2857+
2858+
@classmethod
2859+
def from_environment(cls) -> "ObservabilityConfiguration":
2860+
"""Collect all OTEL_* environment variables from the environment.
2861+
2862+
Sensitive variables (headers, certificates, keys) are redacted with "[REDACTED]".
2863+
2864+
Returns:
2865+
ObservabilityConfiguration with otel dict populated from environment.
2866+
"""
2867+
otel_vars = {}
2868+
for key, value in os.environ.items():
2869+
if key.startswith("OTEL_"):
2870+
# Redact sensitive variables that contain tokens, keys, or certificates
2871+
if key in _SECRET_OTEL_VARS:
2872+
otel_vars[key] = "[REDACTED]"
2873+
else:
2874+
otel_vars[key] = value
2875+
return cls(otel=otel_vars)
2876+
2877+
28332878
class Configuration(ConfigurationBase):
28342879
"""Global service configuration."""
28352880

@@ -2991,6 +3036,13 @@ class Configuration(ConfigurationBase):
29913036
description="Splunk HEC configuration for sending telemetry events.",
29923037
)
29933038

3039+
observability: ObservabilityConfiguration = Field(
3040+
default_factory=ObservabilityConfiguration.from_environment,
3041+
title="Observability configuration",
3042+
description="OpenTelemetry and observability configuration collected "
3043+
"from OTEL_* environment variables.",
3044+
)
3045+
29943046
deployment_environment: str = Field(
29953047
"development",
29963048
title="Deployment environment",

tests/integration/endpoints/test_config_integration.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Integration tests for the /config endpoint."""
22

3+
import os
34
from typing import cast
45

56
import pytest
@@ -88,3 +89,79 @@ async def test_config_endpoint_fails_without_configuration(
8889
assert "response" in exc_info.value.detail
8990
detail = cast(dict[str, str], exc_info.value.detail)
9091
assert "configuration is not loaded" in detail["response"].lower()
92+
93+
94+
@pytest.mark.asyncio
95+
async def test_config_endpoint_includes_observability(
96+
current_config: AppConfig, # pylint: disable=unused-argument
97+
test_request: Request,
98+
test_auth: AuthTuple,
99+
) -> None:
100+
"""Test that config endpoint includes observability configuration.
101+
102+
This integration test verifies:
103+
- Observability field is present in the configuration response
104+
- OTEL environment variables are correctly collected
105+
- Response structure includes observability.otel block
106+
107+
Parameters:
108+
----------
109+
current_config (AppConfig): Loads root configuration
110+
test_request (Request): FastAPI request
111+
test_auth (AuthTuple): noop authentication tuple
112+
"""
113+
response = await config_endpoint_handler(auth=test_auth, request=test_request)
114+
115+
# Verify observability field exists
116+
assert hasattr(response.configuration, "observability")
117+
assert response.configuration.observability is not None
118+
119+
# Verify otel field exists
120+
assert hasattr(response.configuration.observability, "otel")
121+
assert isinstance(response.configuration.observability.otel, dict)
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_config_endpoint_observability_collects_otel_vars(
126+
current_config: AppConfig, # pylint: disable=unused-argument
127+
test_request: Request, # pylint: disable=unused-argument
128+
test_auth: AuthTuple, # pylint: disable=unused-argument
129+
monkeypatch: pytest.MonkeyPatch,
130+
) -> None:
131+
"""Test that observability config collects OTEL_* environment variables.
132+
133+
This integration test verifies:
134+
- OTEL_* environment variables are collected into observability.otel
135+
- Non-OTEL variables are not included
136+
- Environment variables set at runtime are reflected in config
137+
138+
Parameters:
139+
----------
140+
current_config (AppConfig): Loads root configuration
141+
test_request (Request): FastAPI request
142+
test_auth (AuthTuple): noop authentication tuple
143+
monkeypatch (pytest.MonkeyPatch): Fixture to modify environment variables
144+
"""
145+
# pylint: disable=import-outside-toplevel
146+
from models.config import ObservabilityConfiguration
147+
148+
# Set OTEL environment variables
149+
monkeypatch.setenv("OTEL_SDK_DISABLED", "true")
150+
monkeypatch.setenv("OTEL_SERVICE_NAME", "test-service")
151+
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
152+
153+
# Reload configuration to pick up new env vars
154+
updated_observability = ObservabilityConfiguration.from_environment()
155+
156+
# Verify OTEL vars are present in the environment
157+
# (Note: The config is loaded at startup, so these may not be in the response
158+
# unless we reload the entire config. This test verifies the mechanism works.)
159+
assert "OTEL_SDK_DISABLED" in os.environ
160+
assert "OTEL_SERVICE_NAME" in os.environ
161+
assert "OTEL_EXPORTER_OTLP_ENDPOINT" in os.environ
162+
163+
# Verify that when we call from_environment(), it picks up the vars
164+
assert "OTEL_SDK_DISABLED" in updated_observability.otel
165+
assert updated_observability.otel["OTEL_SDK_DISABLED"] == "true"
166+
assert "OTEL_SERVICE_NAME" in updated_observability.otel
167+
assert updated_observability.otel["OTEL_SERVICE_NAME"] == "test-service"

tests/unit/models/config/test_dump_configuration.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
"max_content_length": constants.SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH,
4545
}
4646

47+
_DEFAULT_OBSERVABILITY_DUMP: dict[str, dict[str, str]] = {
48+
"otel": {},
49+
}
50+
4751
_MCP_SERVER_DUMP_DEFAULTS: dict[str, Any] = {
4852
"authorization_headers": {},
4953
"headers": [],
@@ -232,6 +236,7 @@ def test_dump_configuration_minimal_cfg(tmp_path: Path) -> None:
232236
"quota_subject": None,
233237
},
234238
"splunk": None,
239+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
235240
"deployment_environment": "development",
236241
"reranker": {
237242
"enabled": False,
@@ -459,6 +464,7 @@ def test_dump_configuration_valid_values(tmp_path: Path) -> None:
459464
"quota_subject": None,
460465
},
461466
"splunk": None,
467+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
462468
"deployment_environment": "development",
463469
"reranker": {
464470
"enabled": False,
@@ -837,6 +843,7 @@ def test_dump_configuration_with_quota_limiters(tmp_path: Path) -> None:
837843
"quota_subject": None,
838844
},
839845
"splunk": None,
846+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
840847
"deployment_environment": "development",
841848
"reranker": {
842849
"enabled": False,
@@ -1099,6 +1106,7 @@ def test_dump_configuration_with_quota_limiters_different_values(
10991106
"quota_subject": None,
11001107
},
11011108
"splunk": None,
1109+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
11021110
"deployment_environment": "development",
11031111
"reranker": {
11041112
"enabled": False,
@@ -1394,6 +1402,7 @@ def test_dump_configuration_byok(tmp_path: Path) -> None:
13941402
"quota_subject": None,
13951403
},
13961404
"splunk": None,
1405+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
13971406
"deployment_environment": "development",
13981407
"reranker": {
13991408
"enabled": False,
@@ -1616,6 +1625,7 @@ def test_dump_configuration_pg_namespace(tmp_path: Path) -> None:
16161625
"quota_subject": None,
16171626
},
16181627
"splunk": None,
1628+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
16191629
"deployment_environment": "development",
16201630
"reranker": {
16211631
"enabled": False,
@@ -1998,6 +2008,7 @@ def test_dump_configuration_allow_degraded_mode(tmp_path: Path) -> None:
19982008
"quota_subject": None,
19992009
},
20002010
"splunk": None,
2011+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
20012012
"deployment_environment": "development",
20022013
"reranker": {
20032014
"enabled": False,
@@ -2226,6 +2237,7 @@ def test_dump_configuration_max_retries_settings(tmp_path: Path) -> None:
22262237
"quota_subject": None,
22272238
},
22282239
"splunk": None,
2240+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
22292241
"deployment_environment": "development",
22302242
"reranker": {
22312243
"enabled": False,
@@ -2454,6 +2466,7 @@ def test_dump_configuration_retry_count_settings(tmp_path: Path) -> None:
24542466
"quota_subject": None,
24552467
},
24562468
"splunk": None,
2469+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
24572470
"deployment_environment": "development",
24582471
"reranker": {
24592472
"enabled": False,
@@ -2689,6 +2702,7 @@ def test_dump_configuration_specific_compaction_values(tmp_path: Path) -> None:
26892702
"quota_subject": None,
26902703
},
26912704
"splunk": None,
2705+
"observability": _DEFAULT_OBSERVABILITY_DUMP,
26922706
"deployment_environment": "development",
26932707
"reranker": {
26942708
"enabled": False,

0 commit comments

Comments
 (0)