Skip to content

Conversation

alienzach
Copy link
Contributor

@alienzach alienzach commented Oct 9, 2025

Refactored OpenTelemetry Exporter Configuration

TL;DR

Refactored OpenTelemetry exporter configuration to support three schema versions with improved backward compatibility.

What changed?

  • Removed the discriminated union pattern with type field in favor of a more flexible approach
  • Implemented three schema versions for exporter configuration:
    • V1: String exporters with legacy fields (exporters: ["console", "file"])
    • V2: Discriminated union with type field (exporters: [{type: "file", path: "/tmp/out"}])
    • V3: Dict key as discriminator (exporters: [console: {}, file: {path: "trace.jsonl"}])
  • Enhanced validation to properly handle all three formats with appropriate error messages
  • Improved handling of legacy fields (path, path_settings, otlp_settings) for backward compatibility
  • Added comprehensive tests for all schema versions and mixed configurations

How to test?

  1. Test with V1 string exporters:
otel:
  enabled: true
  exporters: ["console", "file", "otlp"]
  path: "/tmp/trace.jsonl"
  otlp_settings:
    endpoint: "http://collector:4318/v1/traces"
  1. Test with V2 type-discriminated exporters:
otel:
  enabled: true
  exporters:
    - type: "console"
    - type: "file"
      path: "/tmp/trace.jsonl"
  1. Test with V3 key-discriminated exporters:
otel:
  enabled: true
  exporters:
    - console: {}
    - file:
        path: "/tmp/trace.jsonl"
    - otlp:
        endpoint: "http://collector:4318/v1/traces"
  1. Run the test suite: pytest tests/test_config_exporters.py

Why make this change?

The previous configuration approach using discriminated unions with a type field was overly restrictive and not as user-friendly. This refactoring:

  1. Makes the configuration more intuitive and flexible for users
  2. Maintains backward compatibility with existing configurations
  3. Allows for multiple exporters of the same type with different configurations
  4. Simplifies the internal model by removing unnecessary inheritance
  5. Provides clearer documentation and error messages for configuration issues

Summary by CodeRabbit

  • New Features

    • More flexible OpenTelemetry exporters: exporters can be simple names or named exporter blocks (e.g., otlp with endpoint/headers); supports multiple config sources.
  • Documentation

    • Updated tracing READMEs and example YAMLs to reflect the new exporters structure and OTLP guidance (endpoints/authorization).
  • Refactor

    • Normalized exporter schema/merging and removed legacy otlp_settings from defaults.
  • Tests

    • Expanded validation and integration tests for mixed-format exporter configs and error cases.

Copy link
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

Copy link

coderabbitai bot commented Oct 9, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Refactors OpenTelemetry exporter config to a single-key mapping/list schema (V3), adds pre/post validators to normalize legacy shapes, updates tracer parsing to accept payload-based exporter entries (console/file/otlp) with legacy fallbacks, and updates examples, schema, and tests accordingly.

Changes

Cohort / File(s) Summary
Tracing README examples
examples/tracing/agent/README.md, examples/tracing/langfuse/README.md, examples/tracing/llm/README.md, examples/tracing/mcp/README.md, examples/tracing/temporal/README.md
Update OTEL exporter examples from inline - type: ... forms to V3 single-key mapping/list form (- console, - file, - otlp: { endpoint: ... }) and adjust wording.
Tracing example configs
examples/tracing/agent/mcp_agent.config.yaml, examples/tracing/langfuse/mcp_agent.config.yaml, examples/tracing/llm/mcp_agent.config.yaml, examples/tracing/mcp/mcp_agent.config.yaml, examples/tracing/temporal/mcp_agent.config.yaml
Convert exporters from inline array/object styles to YAML sequence of single-key mappings; preserve endpoints and commented OTLP examples; reflow indentation.
Workflows example configs
examples/workflows/*/mcp_agent.config.yaml
examples/workflows/workflow_deep_orchestrator/mcp_agent.config.yaml, examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml, examples/workflows/workflow_intent_classifier/mcp_agent.config.yaml, examples/workflows/workflow_orchestrator_worker/mcp_agent.config.yaml, examples/workflows/workflow_parallel/mcp_agent.config.yaml, examples/workflows/workflow_router/mcp_agent.config.yaml
Migrate exporters to V3 mapping list; reformat file exporter path_settings nesting and inline fields; keep commented OTLP hints and adjust indentation.
Config model & schema
src/mcp_agent/config.py, schema/mcp-agent.config.schema.json
Replace discriminator-based exporter models with concrete exporter model classes and a union accepting literal names, single-key dict mappings, or model instances; add @model_validator pre/post hooks to coerce legacy shapes into V3 and finalize exporters; remove TraceOTLPSettings and adjust schema definitions.
Tracer initialization
src/mcp_agent/tracing/tracer.py
Accept literals and single-key dict exporter entries, extract exporter type and payload, construct OTLP/file exporters from payload (endpoint/headers, path/path_settings) with legacy fallbacks, validate path_settings via TracePathSettings, and skip unknown formats.
Tests
tests/test_config_exporters.py
Add/adjust tests for V1/V2/V3 exporter shapes, normalization/finalization behavior, path_settings conversion, OTLP headers, invalid exporter validation, and config+secrets merging; update expectations for removed typed-exporter declarations.
Secrets/example update
examples/tracing/langfuse/mcp_agent.secrets.yaml.example
Move OTLP header config into exporters.otlp structured block (endpoint + headers) and remove previous otlp_settings.headers usage.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant YAML as User Config / Secrets
  participant Settings as OpenTelemetrySettings
  participant Tracer as Tracer Init
  participant Factory as Exporter Factory

  YAML->>Settings: load config (V1/V2/V3 shapes)
  activate Settings
  Settings->>Settings: _coerce_exporters_schema (before)\nnormalize legacy shapes -> V3 single-key maps
  Settings->>Settings: _finalize_exporters (after)\nresolve legacy fields, build final exporters list
  Settings-->>YAML: validated Settings (exporters normalized)
  deactivate Settings

  YAML->>Tracer: init tracer with Settings
  activate Tracer
  loop each exporter entry
    Tracer->>Factory: parse entry -> (type, payload)
    alt console
      Factory-->>Tracer: build console exporter
    else file
      Factory->>Factory: validate/convert path_settings (TracePathSettings)
      Factory-->>Tracer: build file exporter
    else otlp
      Factory->>Factory: resolve endpoint/headers (payload or legacy)
      Factory-->>Tracer: build otlp exporter (or skip if no endpoint)
    else unknown
      Tracer-->>Tracer: log error, skip
    end
  end
  Tracer-->>YAML: tracer provider initialized
  deactivate Tracer
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • rholinshead

Poem

A rabbit hops through YAML groves,
Tidies exporters, nests and hoves.
Console, file, OTLP align,
Validators prune each tangled vine.
Tracer winks — exports now in line 🐇

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly identifies a refactor of OpenTelemetry settings focused on ensuring stable serialization and deserialization, which matches the core changes around removing the discriminated-union pattern and adding schema validations.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3855ae4 and 1ce3a57.

📒 Files selected for processing (6)
  • examples/tracing/langfuse/README.md (1 hunks)
  • examples/tracing/langfuse/mcp_agent.config.yaml (1 hunks)
  • examples/tracing/langfuse/mcp_agent.secrets.yaml.example (1 hunks)
  • src/mcp_agent/config.py (5 hunks)
  • src/mcp_agent/tracing/tracer.py (3 hunks)
  • tests/test_config_exporters.py (3 hunks)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alienzach alienzach force-pushed the 10-09-feat_v3_otel_settings branch from 272f20b to f61ba40 Compare October 9, 2025 11:23
Comment on lines +575 to +577
Dict[Literal["console"], ConsoleExporterSettings | Dict],
Dict[Literal["file"], FileExporterSettings | Dict],
Dict[Literal["otlp"], OTLPExporterSettings | Dict],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the | Dict?

Suggested change
Dict[Literal["console"], ConsoleExporterSettings | Dict],
Dict[Literal["file"], FileExporterSettings | Dict],
Dict[Literal["otlp"], OTLPExporterSettings | Dict],
Dict[Literal["console"], ConsoleExporterSettings],
Dict[Literal["file"], FileExporterSettings],
Dict[Literal["otlp"], OTLPExporterSettings],

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when we validate the schema of concrete type from yaml, we don't know which concrete type it is yet. we have to use dict to allow yaml to be deserialized into BaseModel then create concrete model after we get the type from key.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, but does it cause an issue? Why was the previous case ok where we had it as Union[Literal["console", "file", "otlp"], OpenTelemetryExporterSettings] (no | Dict)

"""Sample rate for tracing (1.0 = sample everything)"""

# Deprecated: use exporters: [{ type: "otlp", ... }]
# Deprecated V1 field: use exporters list with V3 syntax instead
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V1 vs. V3 is quite arbitrary. Just describe what the expected syntax is

Copy link
Collaborator

@saqadri saqadri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are also plenty of examples that need to be updated. Please see the original PR #415

@alienzach alienzach marked this pull request as ready for review October 9, 2025 16:05
@alienzach
Copy link
Contributor Author

@saqadri PR is ready. But how do we want to approach the secret merging in this multi exporters schema?

for example, here: https://github.com/lastmile-ai/mcp-agent/blob/5e99d4dc8294fa3b5f949e2938f8ce16ec620e6e/examples/tracing/langfuse/README.md#2-set-up-secrets-and-environment-variables

we could use endpoint here to match the headers in secret and config, which mean the url have to be specified in both places as a key, also have to be unique?

or maybe I am over thinking?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mcp_agent/tracing/tracer.py (1)

231-233: Fix copy/paste in OpenAI instrumentation error message

Wrong package name; should reference the OpenAI instrumentation.

Apply:

-                logger.error(
-                    "OpenAI OTEL instrumentation not available. Please install opentelemetry-instrumentation-anthropic."
-                )
+                logger.error(
+                    "OpenAI OTEL instrumentation not available. Please install opentelemetry-instrumentation-openai."
+                )
🧹 Nitpick comments (4)
src/mcp_agent/tracing/tracer.py (1)

117-135: Optional: simplify to V3-only exporter parsing

OpenTelemetrySettings._finalize_exporters normalizes to V3 single-key dicts. You can drop the legacy string-path in the tracer for simpler, stricter handling (config preserves back-compat).

Example:

  • Assume exporter is a single-key dict and error if not.
  • Removes isinstance(exporter, str) and unknown format branch.
tests/test_config_exporters.py (2)

4-4: Prefer importing ValidationError from pydantic for stability

Avoid coupling tests to pydantic_core internals; import from pydantic.

Apply:

-from pydantic_core import ValidationError
+from pydantic import ValidationError

360-364: Standardize on ValidationError for schema failures

The model validators raise validation errors; asserting ValidationError is more consistent across cases.

Apply:

-def test_invalid_exporter_format_raises():
-    """Test that invalid exporter formats raise ValueError."""
-    with pytest.raises(ValueError, match="OpenTelemetry exporters must be strings"):
-        OpenTelemetrySettings(exporters=[{"foo": "bar", "baz": "qux"}])  # Multi-key dict
+def test_invalid_exporter_format_raises():
+    """Test that invalid exporter formats raise ValidationError."""
+    with pytest.raises(ValidationError, match="OpenTelemetry exporters must be strings"):
+        OpenTelemetrySettings(exporters=[{"foo": "bar", "baz": "qux"}])  # Multi-key dict
src/mcp_agent/config.py (1)

584-596: Docstring example could use YAML-accurate list syntax

To avoid confusion, show list items with dashes for YAML.

Example:

  • exporters:
    • console: {}
    • file:
      path: "trace.jsonl"
    • otlp:
      endpoint: "https://..."
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 89c0baa and 5e99d4d.

📒 Files selected for processing (19)
  • examples/tracing/agent/README.md (1 hunks)
  • examples/tracing/agent/mcp_agent.config.yaml (1 hunks)
  • examples/tracing/langfuse/README.md (1 hunks)
  • examples/tracing/langfuse/mcp_agent.config.yaml (1 hunks)
  • examples/tracing/llm/README.md (1 hunks)
  • examples/tracing/llm/mcp_agent.config.yaml (1 hunks)
  • examples/tracing/mcp/README.md (1 hunks)
  • examples/tracing/mcp/mcp_agent.config.yaml (1 hunks)
  • examples/tracing/temporal/README.md (1 hunks)
  • examples/tracing/temporal/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_deep_orchestrator/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_intent_classifier/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_orchestrator_worker/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_parallel/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_router/mcp_agent.config.yaml (1 hunks)
  • src/mcp_agent/config.py (4 hunks)
  • src/mcp_agent/tracing/tracer.py (3 hunks)
  • tests/test_config_exporters.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
tests/test_config_exporters.py (1)
src/mcp_agent/config.py (4)
  • OpenTelemetrySettings (565-789)
  • TracePathSettings (498-521)
  • TraceOTLPSettings (524-535)
  • Settings (868-981)
src/mcp_agent/tracing/tracer.py (2)
src/mcp_agent/config.py (2)
  • OpenTelemetrySettings (565-789)
  • TracePathSettings (498-521)
src/mcp_agent/logging/logger.py (1)
  • error (291-299)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: checks / format
  • GitHub Check: update_release_draft
🔇 Additional comments (5)
src/mcp_agent/tracing/tracer.py (2)

166-179: LGTM: path_settings coercion

Good normalization of dict to TracePathSettings to keep FileSpanExporter typed.


136-141: Ignore removal of service_name argument ConsoleSpanExporter’s constructor supports service_name, so no change is needed.

Likely an incorrect or invalid review comment.

tests/test_config_exporters.py (1)

216-231: Solid coverage of V3 dict-key schema

Good assertions on multi-exporter V3 usage and field presence.

src/mcp_agent/config.py (2)

610-675: Schema coercion looks robust

Nice handling of V1 strings, V2 type-discriminators, and V3 key-discriminators, plus BaseModel passthrough.


677-789: Finalization pipeline is clear and backward-compatible

Good use of typed model_validate, legacy fallbacks, and removal of legacy extras to keep dumps clean.

Comment on lines 19 to 20
- console: {}
- file: {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we set these to file and console respectively, which would have them be treated as strings

Copy link
Collaborator

saqadri commented Oct 9, 2025

@alienzach I don't think we should do any sophisticated merge. The secrets one should be updated to match the schema, so you should update that readme and example to show the secrets as:

otel:
  enabled: true
  exporters:
    - otlp:
        endpoint: "https://us.cloud.langfuse.com/api/public/otel/v1/traces"
        headers:
          Authorization: "Basic AUTH_STRING"

In fact, if there are secrets to specify for the Otel exporters, that list item should just be specified in the secrets.yaml instead of config.yaml. Just validate that this case would work:

config.yaml:

otel:
  exporters:
    - otlp:
        endpoint: "https://us.cloud.langfuse.com/api/public/otel/v1/traces"
        headers:
          Authorization: "Basic AUTH_STRING"

secrets.yaml:

otel:
  enabled: true
  exporters:
    - otlp:
        endpoint: "https://some-other-otel-exporter"

Final config should include BOTH in the exporters list.

Copy link
Collaborator

@saqadri saqadri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks good. I haven't looked super carefully at the pre and post validators. Please test the CLI (uv run mcp-agent init), etc. And I left another comment about validating the merge semantics.

Comment on lines 32 to 33
- console: {}
- file: {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these empty cases, let's switch to the string literals...

console
file

Copy link
Collaborator

@saqadri saqadri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some other comments for the overall PR:

  1. Can we please get rid of comment references to "V1", "V2", "V3", etc... those are arbitrary since we don't have any versioning of the config.

  2. Please regenerate the schema with "make schema"

  3. Anywhere we have things like "file: {}", or "console: {}", let's replace with just "file" and "console" respectively, as the string literal will take effect.

Lastly just do some manual testing across the examples to ensure it all works. Thanks for the change!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
examples/tracing/llm/mcp_agent.config.yaml (1)

29-34: Adopt the V3 single-key mapping syntax for exporters.

These entries are still V1-style scalars. To match the new schema (and enable secrets/config merging per exporter), please switch to the V3 shape, e.g.:

-  exporters:
-    - console
-    - file
+  exporters:
+    - console: {}
+    - file:
+        path: "trace.jsonl"

You can tweak the file configuration as needed, but each exporter should be represented as a one-key mapping.

tests/test_config_exporters.py (1)

473-499: Extract duplicated deep_merge function to shared helper.

The deep_merge function is duplicated in two test functions. Consider extracting it to a module-level helper function to improve maintainability and reduce code duplication.

Apply this diff to extract the shared helper:

+def _deep_merge_with_otel_concatenation(base: dict, update: dict, path: tuple = ()) -> dict:
+    """Recursively merge two dictionaries, preserving nested structures.
+
+    Special handling for 'exporters' lists under 'otel' key:
+    - Concatenates lists instead of replacing them
+    - Allows combining exporters from config and secrets files
+    """
+    merged = base.copy()
+    for key, value in update.items():
+        current_path = path + (key,)
+        if (
+            key in merged
+            and isinstance(merged[key], dict)
+            and isinstance(value, dict)
+        ):
+            merged[key] = _deep_merge_with_otel_concatenation(merged[key], value, current_path)
+        elif (
+            key in merged
+            and isinstance(merged[key], list)
+            and isinstance(value, list)
+            and current_path == ("otel", "exporters")
+        ):
+            # Concatenate exporters lists from config and secrets
+            merged[key] = merged[key] + value
+        else:
+            merged[key] = value
+    return merged
+
+
 def test_merge_otel_exporters_from_config_and_secrets():
     """Test that OTEL exporters from config.yaml and secrets.yaml are merged together."""
-    from mcp_agent.config import get_settings
 
     # Simulate config.yaml with one OTLP exporter (public endpoint)
     config_data = {
         ...
     }
 
     # Simulate secrets.yaml with another OTLP exporter (secret endpoint)
     secrets_data = {
         ...
     }
 
-    # Manually perform deep merge as get_settings does internally
-    def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
-        """Recursively merge two dictionaries, preserving nested structures.
-
-        Special handling for 'exporters' lists under 'otel' key:
-        - Concatenates lists instead of replacing them
-        - Allows combining exporters from config and secrets files
-        """
-        merged = base.copy()
-        for key, value in update.items():
-            current_path = path + (key,)
-            if (
-                key in merged
-                and isinstance(merged[key], dict)
-                and isinstance(value, dict)
-            ):
-                merged[key] = deep_merge(merged[key], value, current_path)
-            elif (
-                key in merged
-                and isinstance(merged[key], list)
-                and isinstance(value, list)
-                and current_path == ("otel", "exporters")
-            ):
-                # Concatenate exporters lists from config and secrets
-                merged[key] = merged[key] + value
-            else:
-                merged[key] = value
-        return merged
-
-    merged = deep_merge(config_data, secrets_data)
+    merged = _deep_merge_with_otel_concatenation(config_data, secrets_data)
     settings = Settings(**merged)
     ...

Then update test_merge_non_otel_lists_are_replaced_not_concatenated similarly:

 def test_merge_non_otel_lists_are_replaced_not_concatenated():
     """Test that non-OTEL lists are replaced, not concatenated (default behavior)."""
-    # Manually perform deep merge as get_settings does internally
-    def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
-        ...
-        return merged
-
     # Test with logger.transports (should be replaced, not concatenated)
     config_data = {
         ...
     }
     secrets_data = {
         ...
     }
-    merged = deep_merge(config_data, secrets_data)
+    merged = _deep_merge_with_otel_concatenation(config_data, secrets_data)
     ...

Also applies to: 525-551

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5e99d4d and 35f094f.

📒 Files selected for processing (15)
  • examples/tracing/agent/README.md (1 hunks)
  • examples/tracing/agent/mcp_agent.config.yaml (1 hunks)
  • examples/tracing/langfuse/README.md (1 hunks)
  • examples/tracing/langfuse/mcp_agent.secrets.yaml.example (1 hunks)
  • examples/tracing/llm/README.md (1 hunks)
  • examples/tracing/llm/mcp_agent.config.yaml (1 hunks)
  • examples/tracing/temporal/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_intent_classifier/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_orchestrator_worker/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_parallel/mcp_agent.config.yaml (1 hunks)
  • examples/workflows/workflow_router/mcp_agent.config.yaml (1 hunks)
  • src/mcp_agent/config.py (5 hunks)
  • src/mcp_agent/tracing/tracer.py (3 hunks)
  • tests/test_config_exporters.py (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
  • examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml
  • examples/workflows/workflow_orchestrator_worker/mcp_agent.config.yaml
  • examples/tracing/agent/mcp_agent.config.yaml
  • examples/workflows/workflow_parallel/mcp_agent.config.yaml
  • examples/tracing/llm/README.md
  • examples/workflows/workflow_router/mcp_agent.config.yaml
  • examples/tracing/langfuse/README.md
🧰 Additional context used
🧬 Code graph analysis (3)
src/mcp_agent/tracing/tracer.py (1)
src/mcp_agent/config.py (2)
  • OpenTelemetrySettings (565-788)
  • TracePathSettings (498-521)
tests/test_config_exporters.py (1)
src/mcp_agent/config.py (6)
  • OpenTelemetrySettings (565-788)
  • TracePathSettings (498-521)
  • TraceOTLPSettings (524-535)
  • Settings (867-980)
  • get_settings (1047-1161)
  • deep_merge (1059-1085)
src/mcp_agent/config.py (1)
tests/test_config_exporters.py (2)
  • deep_merge (473-499)
  • deep_merge (525-551)
🪛 GitHub Actions: Pull Request Checks
tests/test_config_exporters.py

[error] 448-448: Ruff check failed. Unused import: F401 in tests/test_config_exporters.py: 'from mcp_agent.config import get_settings'. Remove unused import. 1 fixable with --fix. Command: ruff check

🔇 Additional comments (7)
examples/tracing/langfuse/mcp_agent.secrets.yaml.example (1)

11-15: No action required – otel.exporters lists are concatenated via deep_merge in get_settings (and covered by test_merge_otel_exporters_from_config_and_secrets).

tests/test_config_exporters.py (1)

1-603: Excellent test coverage for multi-version schema support.

The test suite comprehensively validates:

  • V1 string exporters with legacy fields
  • V2 type-discriminated format with type field
  • V3 key-discriminated format with dict keys
  • Cross-version compatibility and mixed configurations
  • Config/secrets merging behavior for exporters
  • Error handling for invalid formats

The assertion helpers (_assert_console_exporter, _assert_file_exporter, _assert_otlp_exporter) provide clear, reusable validation logic for the V3 dict-based format.

src/mcp_agent/config.py (5)

537-562: LGTM: Clean exporter settings models.

The new exporter settings classes (ConsoleExporterSettings, FileExporterSettings, OTLPExporterSettings) are well-designed with appropriate fields and proper configuration. The OpenTelemetryExporterSettings union alias provides clear type semantics.


572-596: Flexible type definition supports multi-version schemas.

The exporters type definition accommodates all three schema versions (V1 strings, V2 type-discriminated, V3 key-discriminated) while maintaining backward compatibility. The | Dict annotations allow initial dictionary parsing during YAML deserialization before concrete types are determined in the validators.

The documentation clearly explains the supported formats with helpful examples for each version.

Based on past review comments, the | Dict annotation was discussed and justified for YAML deserialization needs.


607-672: LGTM: Comprehensive schema normalization.

The _coerce_exporters_schema validator effectively normalizes the three exporter formats:

  • V1 string exporters preserved for finalization with legacy fields
  • V2 type-discriminated format {type: "X", ...} converted to V3 {X: {...}}
  • V3 key-discriminated format preserved as-is
  • BaseModel instances handled correctly

The error message at lines 664-668 clearly describes expected formats, which will help users understand validation failures.


674-788: LGTM: Proper finalization and legacy field handling.

The _finalize_exporters validator correctly:

  • Extracts and normalizes legacy top-level fields (path, path_settings, otlp_settings)
  • Converts all exporters to uniform V3 key-discriminated dict format
  • Applies legacy field fallbacks for V1 string exporters
  • Validates typed settings models for each exporter type
  • Removes legacy fields to prevent leakage in serialization

The conversion ensures that re-serialization and re-validation work correctly with the V3 format, maintaining consistency across config persistence operations.


1059-1085: LGTM: Correct exporter list concatenation for config/secrets merging.

The updated deep_merge function properly implements the special-case handling for otel.exporters lists:

  • Uses path tracking to precisely identify ("otel", "exporters") context
  • Concatenates exporter lists instead of replacing them
  • Maintains standard merge behavior for other list types
  • Enables combining exporters from config.yaml and secrets.yaml

This implementation matches the merge behavior validated in the test suite and supports the use case described in the PR comments where config and secrets each specify different OTLP exporters that should both be active.

@alienzach alienzach changed the title feat: v3 otel settings refactor: otel settings have stable (de)serialization Oct 9, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/test_config_exporters.py (2)

6-11: DRY the deep_merge helper; import the canonical implementation.

Avoid duplicating merge logic in tests; import deep_merge from mcp_agent.config and remove both local definitions. Keeps tests aligned with production behavior and reduces drift.

Imports update:

 from mcp_agent.config import (
     OpenTelemetrySettings,
     Settings,
     TraceOTLPSettings,
     TracePathSettings,
+    deep_merge,
 )

Remove local helper in test_merge_otel_exporters_from_config_and_secrets:

-    # Manually perform deep merge as get_settings does internally
-    def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
-        """Recursively merge two dictionaries, preserving nested structures.
-
-        Special handling for 'exporters' lists under 'otel' key:
-        - Concatenates lists instead of replacing them
-        - Allows combining exporters from config and secrets files
-        """
-        merged = base.copy()
-        for key, value in update.items():
-            current_path = path + (key,)
-            if (
-                key in merged
-                and isinstance(merged[key], dict)
-                and isinstance(value, dict)
-            ):
-                merged[key] = deep_merge(merged[key], value, current_path)
-            elif (
-                key in merged
-                and isinstance(merged[key], list)
-                and isinstance(value, list)
-                and current_path == ("otel", "exporters")
-            ):
-                # Concatenate exporters lists from config and secrets
-                merged[key] = merged[key] + value
-            else:
-                merged[key] = value
-        return merged

Remove local helper in test_merge_non_otel_lists_are_replaced_not_concatenated:

-    # Manually perform deep merge as get_settings does internally
-    def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
-        """Recursively merge two dictionaries, preserving nested structures.
-
-        Special handling for 'exporters' lists under 'otel' key:
-        - Concatenates lists instead of replacing them
-        - Allows combining exporters from config and secrets files
-        """
-        merged = base.copy()
-        for key, value in update.items():
-            current_path = path + (key,)
-            if (
-                key in merged
-                and isinstance(merged[key], dict)
-                and isinstance(value, dict)
-            ):
-                merged[key] = deep_merge(merged[key], value, current_path)
-            elif (
-                key in merged
-                and isinstance(merged[key], list)
-                and isinstance(value, list)
-                and current_path == ("otel", "exporters")
-            ):
-                # Concatenate exporters lists from config and secrets
-                merged[key] = merged[key] + value
-            else:
-                merged[key] = value
-        return merged

Also applies to: 471-499, 524-551


4-4: Import ValidationError from pydantic for stability.

Use pydantic.ValidationError rather than pydantic_core to match public API and future-proof across versions.

Please confirm your pinned Pydantic version; both v1 and v2 expose pydantic.ValidationError.

-from pydantic_core import ValidationError
+from pydantic import ValidationError
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 35f094f and 3b0233f.

📒 Files selected for processing (1)
  • tests/test_config_exporters.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/test_config_exporters.py (1)
src/mcp_agent/config.py (5)
  • OpenTelemetrySettings (565-788)
  • TracePathSettings (498-521)
  • TraceOTLPSettings (524-535)
  • Settings (867-980)
  • deep_merge (1059-1085)

and current_path == ("otel", "exporters")
):
# Concatenate exporters lists from config and secrets
merged[key] = merged[key] + value
Copy link
Contributor Author

@alienzach alienzach Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@saqadri @rholinshead call for last review before merge. especially this chunk of update, which touches the common part for merging. Test cases is in test_config_exporter.py:446-602

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to handle list specially in merge, instead of replace, we concat them. I currently only let this apply to otel.exporters. But I think in the future, we may need similar merge when we have secrets in list data structure again.

@saqadri
Copy link
Collaborator

saqadri commented Oct 10, 2025

@alienzach I passed this diff through GPT-5 and it raised these two valid points:

  • Blocking: examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml:45 now enables both the file exporter and an OTLP exporter with a live http://
    localhost:4318 endpoint by default. As soon as someone flips enabled: true (per the README guidance) the example will try to ship spans to a collector that
    almost certainly isn’t running, which is a regression from the previous safe “console only” setup. Please restore the prior defaults (console with the OTLP
    example commented out) so enabling OTEL doesn’t break out of the box.

  • Blocking: tests/test_config_exporters.py:362 is asserting a ValueError, but the pre-validator raises a ValueError that Pydantic wraps into a ValidationError.
    The test never actually passes this with pytest.raises(ValueError) guard, so the suite will fail. Update the expectation to pytest.raises(ValidationError, …)
    (and keep the message check) to cover the real surface behavior.

@saqadri
Copy link
Collaborator

saqadri commented Oct 10, 2025

@alienzach I passed this diff through GPT-5 and it raised these two valid points:

  • Blocking: examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml:45 now enables both the file exporter and an OTLP exporter with a live http://
    localhost:4318 endpoint by default. As soon as someone flips enabled: true (per the README guidance) the example will try to ship spans to a collector that
    almost certainly isn’t running, which is a regression from the previous safe “console only” setup. Please restore the prior defaults (console with the OTLP
    example commented out) so enabling OTEL doesn’t break out of the box.
  • Blocking: tests/test_config_exporters.py:362 is asserting a ValueError, but the pre-validator raises a ValueError that Pydantic wraps into a ValidationError.
    The test never actually passes this with pytest.raises(ValueError) guard, so the suite will fail. Update the expectation to pytest.raises(ValidationError, …)
    (and keep the message check) to cover the real surface behavior.

@alienzach fixed

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml (1)

44-48: Update example to show the V3 dictionary-key schema.

Now that the docs steer folks toward the V3 representation, let’s keep the example consistent by changing the console entry to - console: {} (and the commented OTLP example to # - otlp: { endpoint: ... }). That way we demo the modern shape while still keeping console-only defaults.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3b0233f and 5f2c904.

📒 Files selected for processing (2)
  • examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml (1 hunks)
  • tests/test_config_exporters.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tests/test_config_exporters.py (1)
src/mcp_agent/config.py (5)
  • OpenTelemetrySettings (565-788)
  • TracePathSettings (498-521)
  • TraceOTLPSettings (524-535)
  • Settings (867-980)
  • deep_merge (1059-1085)
🔇 Additional comments (4)
tests/test_config_exporters.py (4)

1-47: LGTM! Clean imports and helper functions.

The imports are correct, and the helper assertion functions provide clear, reusable utilities for verifying exporter configurations across different formats.


50-296: LGTM! Comprehensive test coverage for all schema versions.

The tests thoroughly validate V1 (string with legacy fields), V2 (type-discriminated), and V3 (key-discriminated) exporter formats. Good coverage of edge cases including BaseModel instances, empty configs, and multiple exporters of the same type.


303-373: LGTM! Cross-version compatibility and error handling are well tested.

The tests validate format mixing, automatic conversion, and proper cleanup of legacy fields. Error tests correctly expect ValidationError as noted in previous review feedback.


380-443: LGTM! Integration tests validate end-to-end behavior.

The tests successfully verify that all three schema versions work correctly when loaded through the full Settings model, including proper handling of additional fields like service_name and sample_rate.

Comment on lines 446 to 602
def test_merge_otel_exporters_from_config_and_secrets():
"""Test that OTEL exporters from config.yaml and secrets.yaml are merged together."""

# Simulate config.yaml with one OTLP exporter (public endpoint)
config_data = {
"otel": {
"exporters": [
{"otlp": {
"endpoint": "https://us.cloud.langfuse.com/api/public/otel/v1/traces",
"headers": {"Authorization": "Basic AUTH_STRING"}
}}
]
}
}

# Simulate secrets.yaml with another OTLP exporter (secret endpoint)
secrets_data = {
"otel": {
"enabled": True,
"exporters": [
{"otlp": {"endpoint": "https://some-other-otel-exporter"}}
]
}
}

# Manually perform deep merge as get_settings does internally
def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
"""Recursively merge two dictionaries, preserving nested structures.
Special handling for 'exporters' lists under 'otel' key:
- Concatenates lists instead of replacing them
- Allows combining exporters from config and secrets files
"""
merged = base.copy()
for key, value in update.items():
current_path = path + (key,)
if (
key in merged
and isinstance(merged[key], dict)
and isinstance(value, dict)
):
merged[key] = deep_merge(merged[key], value, current_path)
elif (
key in merged
and isinstance(merged[key], list)
and isinstance(value, list)
and current_path == ("otel", "exporters")
):
# Concatenate exporters lists from config and secrets
merged[key] = merged[key] + value
else:
merged[key] = value
return merged

merged = deep_merge(config_data, secrets_data)
settings = Settings(**merged)

# Verify both exporters are present
assert settings.otel.enabled is True
assert len(settings.otel.exporters) == 2

# Verify first exporter (from config.yaml)
_assert_otlp_exporter(
settings.otel.exporters[0],
endpoint="https://us.cloud.langfuse.com/api/public/otel/v1/traces",
headers={"Authorization": "Basic AUTH_STRING"}
)

# Verify second exporter (from secrets.yaml)
_assert_otlp_exporter(
settings.otel.exporters[1],
endpoint="https://some-other-otel-exporter"
)


def test_merge_non_otel_lists_are_replaced_not_concatenated():
"""Test that non-OTEL lists are replaced, not concatenated (default behavior)."""
# Manually perform deep merge as get_settings does internally
def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
"""Recursively merge two dictionaries, preserving nested structures.
Special handling for 'exporters' lists under 'otel' key:
- Concatenates lists instead of replacing them
- Allows combining exporters from config and secrets files
"""
merged = base.copy()
for key, value in update.items():
current_path = path + (key,)
if (
key in merged
and isinstance(merged[key], dict)
and isinstance(value, dict)
):
merged[key] = deep_merge(merged[key], value, current_path)
elif (
key in merged
and isinstance(merged[key], list)
and isinstance(value, list)
and current_path == ("otel", "exporters")
):
# Concatenate exporters lists from config and secrets
merged[key] = merged[key] + value
else:
merged[key] = value
return merged

# Test with logger.transports (should be replaced, not concatenated)
config_data = {
"logger": {
"transports": ["console", "file"]
}
}
secrets_data = {
"logger": {
"transports": ["http"]
}
}
merged = deep_merge(config_data, secrets_data)
# Should be replaced, not concatenated
assert merged["logger"]["transports"] == ["http"]
assert len(merged["logger"]["transports"]) == 1

# Test with mcp.servers (dict, should be merged)
config_data = {
"mcp": {
"servers": {
"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}
}
}
}
secrets_data = {
"mcp": {
"servers": {
"filesystem": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}
}
}
}
merged = deep_merge(config_data, secrets_data)
# Both servers should be present (dicts are merged)
assert "fetch" in merged["mcp"]["servers"]
assert "filesystem" in merged["mcp"]["servers"]

# Test with a nested list that's NOT otel.exporters (should be replaced)
config_data = {
"agents": {
"search_paths": [".claude/agents", "~/.claude/agents"]
}
}
secrets_data = {
"agents": {
"search_paths": [".mcp-agent/agents"]
}
}
merged = deep_merge(config_data, secrets_data)
# Should be replaced, not concatenated
assert merged["agents"]["search_paths"] == [".mcp-agent/agents"]
assert len(merged["agents"]["search_paths"]) == 1
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Eliminate duplicate deep_merge implementations.

The deep_merge function is defined identically in both test functions (lines 472-498 and 524-550), duplicating the implementation from src/mcp_agent/config.py (lines 1058-1084). This violates DRY and creates maintenance burden.

Import deep_merge from config.py instead:

+from mcp_agent.config import (
+    OpenTelemetrySettings,
+    Settings,
+    TraceOTLPSettings,
+    TracePathSettings,
+    deep_merge,
+)

Then remove the duplicate definitions from both test functions:

 def test_merge_otel_exporters_from_config_and_secrets():
     """Test that OTEL exporters from config.yaml and secrets.yaml are merged together."""

     # Simulate config.yaml with one OTLP exporter (public endpoint)
     config_data = {
         "otel": {
             "exporters": [
                 {"otlp": {
                     "endpoint": "https://us.cloud.langfuse.com/api/public/otel/v1/traces",
                     "headers": {"Authorization": "Basic AUTH_STRING"}
                 }}
             ]
         }
     }

     # Simulate secrets.yaml with another OTLP exporter (secret endpoint)
     secrets_data = {
         "otel": {
             "enabled": True,
             "exporters": [
                 {"otlp": {"endpoint": "https://some-other-otel-exporter"}}
             ]
         }
     }

-    # Manually perform deep merge as get_settings does internally
-    def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
-        """Recursively merge two dictionaries, preserving nested structures.
-
-        Special handling for 'exporters' lists under 'otel' key:
-        - Concatenates lists instead of replacing them
-        - Allows combining exporters from config and secrets files
-        """
-        merged = base.copy()
-        for key, value in update.items():
-            current_path = path + (key,)
-            if (
-                key in merged
-                and isinstance(merged[key], dict)
-                and isinstance(value, dict)
-            ):
-                merged[key] = deep_merge(merged[key], value, current_path)
-            elif (
-                key in merged
-                and isinstance(merged[key], list)
-                and isinstance(value, list)
-                and current_path == ("otel", "exporters")
-            ):
-                # Concatenate exporters lists from config and secrets
-                merged[key] = merged[key] + value
-            else:
-                merged[key] = value
-        return merged
-
     merged = deep_merge(config_data, secrets_data)
     settings = Settings(**merged)

Apply the same change to test_merge_non_otel_lists_are_replaced_not_concatenated (remove lines 524-550).

🤖 Prompt for AI Agents
In tests/test_config_exporters.py around lines 446 to 602, remove the duplicated
deep_merge function definitions inside
test_merge_otel_exporters_from_config_and_secrets and
test_merge_non_otel_lists_are_replaced_not_concatenated and instead import the
canonical implementation from the package config module; add a single import
like "from mcp_agent.config import deep_merge" at the top of the test file (or
the appropriate import path for your package), delete the two local deep_merge
definitions, and run tests to ensure names and behavior match the original
implementation.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
tests/test_config_exporters.py (1)

472-498: Extract duplicated deep_merge function to reduce maintenance burden.

The deep_merge function is defined identically in two separate tests (lines 472-498 and 524-550). This duplication violates the DRY principle and increases maintenance risk.

Option 1 (recommended): Extract to a module-level helper function:

+def _deep_merge_for_testing(base: dict, update: dict, path: tuple = ()) -> dict:
+    """Recursively merge two dictionaries, preserving nested structures.
+
+    Special handling for 'exporters' lists under 'otel' key:
+    - Concatenates lists instead of replacing them
+    - Allows combining exporters from config and secrets files
+    """
+    merged = base.copy()
+    for key, value in update.items():
+        current_path = path + (key,)
+        if (
+            key in merged
+            and isinstance(merged[key], dict)
+            and isinstance(value, dict)
+        ):
+            merged[key] = _deep_merge_for_testing(merged[key], value, current_path)
+        elif (
+            key in merged
+            and isinstance(merged[key], list)
+            and isinstance(value, list)
+            and current_path == ("otel", "exporters")
+        ):
+            # Concatenate exporters lists from config and secrets
+            merged[key] = merged[key] + value
+        else:
+            merged[key] = value
+    return merged
+
+
 def test_merge_otel_exporters_from_config_and_secrets():
     """Test that OTEL exporters from config.yaml and secrets.yaml are merged together."""
     
     # ...
-    def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
-        # ... (remove inline definition)
-    
-    merged = deep_merge(config_data, secrets_data)
+    merged = _deep_merge_for_testing(config_data, secrets_data)

Option 2: Import and test the actual implementation from mcp_agent.config:

+from mcp_agent.config import deep_merge
+
 def test_merge_otel_exporters_from_config_and_secrets():
     """Test that OTEL exporters from config.yaml and secrets.yaml are merged together."""
     
     # ...
-    def deep_merge(base: dict, update: dict, path: tuple = ()) -> dict:
-        # ... (remove inline definition)
-    
     merged = deep_merge(config_data, secrets_data)

Note: If you choose Option 2, ensure that deep_merge is exported from mcp_agent.config or consider importing from the internal location where it's defined.

Also applies to: 524-550

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3b0233f and 3855ae4.

📒 Files selected for processing (3)
  • examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml (1 hunks)
  • schema/mcp-agent.config.schema.json (5 hunks)
  • tests/test_config_exporters.py (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • examples/workflows/workflow_evaluator_optimizer/mcp_agent.config.yaml
🧰 Additional context used
🧬 Code graph analysis (1)
tests/test_config_exporters.py (1)
src/mcp_agent/config.py (5)
  • OpenTelemetrySettings (565-788)
  • TracePathSettings (498-521)
  • TraceOTLPSettings (524-535)
  • Settings (867-980)
  • deep_merge (1059-1085)
🔇 Additional comments (2)
tests/test_config_exporters.py (1)

1-602: Excellent test coverage and organization!

The test suite is comprehensive and well-structured:

  • Clear separation of V1, V2, and V3 format tests
  • Cross-version compatibility validation
  • Error handling for invalid configurations
  • Integration tests with the full Settings model
  • Merge behavior tests for config/secrets scenarios

The docstrings clearly explain what each test validates, making the test suite easy to maintain and understand.

schema/mcp-agent.config.schema.json (1)

1022-1080: Schema correctly implements multi-version exporter configuration.

The exporters schema properly supports all three configuration formats:

  1. V1 (string literals): "console", "file", "otlp"
  2. V2 (type-discriminated): Handled by validator, not schema-enforced
  3. V3 (key-discriminated): Correctly uses propertyNames with const to enforce single-key objects

The use of propertyNames: {const: "console"} is the correct JSON Schema approach to restrict an object to a single, specific key. This ensures that key-discriminated exporters like {console: {}} or {file: {path: "..."}} are properly validated.

The schema maintains backward compatibility by accepting both the key-discriminated format and direct references to typed settings classes.

@saqadri
Copy link
Collaborator

saqadri commented Oct 10, 2025

Going to merge this in so we can get this published. Nice work @alienzach!

@saqadri saqadri merged commit 5c7bd5c into main Oct 10, 2025
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants