Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions src/mcp_agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,24 @@ class OpenTelemetrySettings(BaseModel):

model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)

@staticmethod
def _guess_exporter_type(entry: Dict[str, Any]) -> str | None:
"""
Infer an exporter type when an entry is missing the explicit `type`.

This helps when secrets overlays include only secret-bearing fields and
rely on the base config to supply the exporter type.
"""

otlp_keys = {"endpoint", "headers", "compression", "certificate_file"}
file_keys = {"path", "path_settings"}

if any(key in entry for key in otlp_keys):
return "otlp"
if any(key in entry for key in file_keys):
return "file"
return None

@model_validator(mode="before")
@classmethod
def _coerce_exporters_schema(cls, data: Dict) -> Dict:
Expand All @@ -618,11 +636,34 @@ def _coerce_exporters_schema(cls, data: Dict) -> Dict:

exporters = data.get("exporters")

# If exporters are already objects with a 'type', leave as-is
if isinstance(exporters, list) and all(
isinstance(e, dict) and "type" in e for e in exporters
):
return data
if isinstance(exporters, list):
normalized_exporters: List[Any] = []

for raw_entry in exporters:
entry = raw_entry
if isinstance(entry, BaseModel):
entry = entry.model_dump(exclude_none=True)

if isinstance(entry, dict):
entry_dict = dict(entry)
exporter_type = entry_dict.get("type")
if not exporter_type:
inferred_type = cls._guess_exporter_type(entry_dict)
if inferred_type:
entry_dict = {"type": inferred_type, **entry_dict}
else:
raise ValueError(
"OpenTelemetry exporter entries must include a 'type'. "
"Unable to infer exporter type from fields "
f"{sorted(entry_dict.keys())}. "
"Ensure each exporter in secrets overlays sets `type`."
)
normalized_exporters.append(entry_dict)
else:
normalized_exporters.append(entry)

data["exporters"] = normalized_exporters
exporters = data["exporters"]

# If exporters are literal strings, up-convert to typed configs
if isinstance(exporters, list) and all(isinstance(e, str) for e in exporters):
Expand Down
44 changes: 44 additions & 0 deletions tests/test_config_exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,50 @@ def test_literal_exporters_become_typed_configs():
]


def test_infers_missing_type_for_otlp_exporter():
settings = OpenTelemetrySettings(
exporters=[
{
"endpoint": "http://collector:4318/v1/traces",
"headers": {"Authorization": "secret"},
}
]
)

assert len(settings.exporters) == 1
_assert_otlp_exporter(settings.exporters[0], "http://collector:4318/v1/traces")
assert settings.exporters[0].headers == {"Authorization": "secret"}


def test_infers_missing_type_for_otlp_headers_only():
settings = OpenTelemetrySettings(
exporters=[{"headers": {"Authorization": "secret-handle"}}]
)

assert len(settings.exporters) == 1
assert isinstance(settings.exporters[0], OTLPExporterSettings)
assert settings.exporters[0].type == "otlp"
assert settings.exporters[0].headers == {"Authorization": "secret-handle"}


def test_infers_missing_type_for_file_exporter():
settings = OpenTelemetrySettings(
exporters=[{"path_settings": {"path_pattern": "traces/{unique_id}.jsonl"}}]
)

assert len(settings.exporters) == 1
_assert_file_exporter(settings.exporters[0])
assert settings.exporters[0].path_settings
assert (
settings.exporters[0].path_settings.path_pattern == "traces/{unique_id}.jsonl"
)


def test_missing_type_and_unrecognized_fields_raises():
with pytest.raises(ValueError, match="must include a 'type'"):
OpenTelemetrySettings(exporters=[{"foo": "bar"}])


def test_settings_default_construction_uses_typed_exporters():
settings = Settings()

Expand Down
Loading