Skip to content

opentelemetry-configuration: treat present-null config value as empty mapping#5454

Open
bourbonkk wants to merge 6 commits into
open-telemetry:mainfrom
bourbonkk:fix/5451-null-config-value-parsing
Open

opentelemetry-configuration: treat present-null config value as empty mapping#5454
bourbonkk wants to merge 6 commits into
open-telemetry:mainfrom
bourbonkk:fix/5451-null-config-value-parsing

Conversation

@bourbonkk

Copy link
Copy Markdown
Contributor

Description

Fixes #5451

A YAML mapping key present with an empty (null) value (e.g. always_on:)
parses to None, which the config conversion layer could not distinguish
from an absent key. Since sampler and resource-detector type dispatch selects
a type via is not None, a config like:

sampler:
  always_on:

or the parent_based example from the issue failed with
ConfigurationError: Unsupported sampler type in config: Sampler(always_on=None, ...),
while always_on: {} worked. The same root cause silently skipped detectors
written as - service: (vs - service: {}), as noted by @emdneto.

Per the declarative-config spec, a key present with a null value on a
dict[str, Any]-typed node means "select this with an empty config", so
_convert_value now coerces such a None to an empty mapping. Scalar and
dataclass fields keep None, so an absent optional section stays unset
(existing test_none_value_preserved behavior is unchanged).

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

uv run tox -e py312-test-opentelemetry-configuration (372 passed).

Added regression tests, verified they fail without the fix (with the exact
error from the issue) and pass with it:

  • test_conversion.py: present-null mapping → {}, explicit {} preserved,
    absent key stays None, present-null scalar stays None.
  • test_tracer_provider.py: {"always_on": None} and the full null-valued
    parent_based delegate config from the issue, driven through
    _dict_to_dataclasscreate_tracer_provider.
  • test_resource.py: null-valued detector entry ({"service": None}) runs
    the detector.

Does This PR Require a Contrib Repo Change?

  • Yes. - Link to PR:
  • No.

Checklist:

  • Followed the style guidelines of this project
  • Changelogs have been updated
  • Unit tests have been added
  • Documentation has been updated

🤖 Generated with Claude Code

@bourbonkk
bourbonkk requested a review from a team as a code owner July 23, 2026 08:03
@linux-foundation-easycla

linux-foundation-easycla Bot commented Jul 23, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: bourbonkk / name: allen (bd1aa15)

A YAML mapping key present with an empty (null) value parses to `None`,
which the config conversion layer could not distinguish from an absent
key. Since sampler and resource-detector type dispatch selects a type via
`is not None`, writing a leaf node as `always_on:` (rather than
`always_on: {}`) produced an all-`None` config and failed with
"Unsupported sampler type in config"; detectors written as `- service:`
were silently skipped.

Per the declarative-config spec a key present with a null value on a
`dict[str, Any]`-typed node means "select this with an empty config", so
coerce such a `None` to an empty mapping in `_convert_value`. Scalar and
dataclass fields keep `None`, so an absent optional section stays unset.
@bourbonkk

Copy link
Copy Markdown
Contributor Author

/easycla

@xrmx xrmx changed the title Treat present-null config value as empty mapping opentelemetry-configuration: treat present-null config value as empty mapping Jul 23, 2026
@bourbonkk
bourbonkk force-pushed the fix/5451-null-config-value-parsing branch from 2422177 to bd1aa15 Compare July 23, 2026 08:48
bourbonkk added a commit to bourbonkk/opentelemetry-python that referenced this pull request Jul 23, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@xrmx xrmx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Works fine, thanks! I think we can improve the tests though

Comment thread opentelemetry-configuration/tests/test_resource.py
@xrmx xrmx moved this to Reviewed PRs that need fixes in Python PR digest Jul 23, 2026
@github-project-automation github-project-automation Bot moved this from Reviewed PRs that need fixes to Approved PRs in Python PR digest Jul 23, 2026

@emdneto emdneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The issue is not only for sampler, detectors, but for every dataclass configuration that can be instantiated with an empty configuration. Let's say: ConsoleMetricExporter. I think we should also cover the dataclasses besides dict. e.g.,

meter_provider:
  readers:
    - periodic:
        exporter:
          console:

I believe this should work without requiring us to pass console: {}.

@github-project-automation github-project-automation Bot moved this from Approved PRs to Reviewed PRs that need fixes in Python PR digest Jul 23, 2026
@xrmx

xrmx commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The issue is not only for sampler, detectors, but for every dataclass configuration that can be instantiated with an empty configuration. Let's say: ConsoleMetricExporter. I think we should also cover the dataclasses besides dict. e.g.,

meter_provider:
  readers:
    - periodic:
        exporter:
          console:

I believe this should work without requiring us to pass console: {}.

Great catch, I assumed that everything was becoming a dict. Something like the following may do but I really haven't grokked the code:

+    if value is None:
+        if origin is dict:
+            return {}
+        if origin is None and is_dataclass(unwrapped):
+            return unwrapped
+        return None

Comment thread opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py Outdated
…-telemetry#5451)

Address review feedback: the empty-value issue affects not only dict-aliased
nodes (sampler/detector leaves) but any dataclass config that can be
instantiated with no arguments, e.g. a metric `console:` exporter written
without an explicit `console: {}`.

_convert_value now also coerces a present null to a defaulted dataclass
instance when the annotated type is a dataclass whose fields are all
optional. Dataclasses with required fields, and scalar fields, keep None so
an absent optional section stays unset and no TypeError is raised.

Adds regression coverage for the null-valued metric console exporter and for
the required-field guard.
@bourbonkk

Copy link
Copy Markdown
Contributor Author

Good point, thanks — you're right that it's broader than dict-aliased nodes. I've extended the coercion so a present null is also mapped to a defaulted instance when the annotated type is a dataclass that can be built with no arguments, which covers the console: exporter case:

def _coerce_present_null(unwrapped, origin):
    if origin is dict:
        return {}
    if (
        origin is None
        and isinstance(unwrapped, type)
        and is_dataclass(unwrapped)
        and all(
            field.default is not MISSING or field.default_factory is not MISSING
            for field in fields(unwrapped)
        )
    ):
        return _dict_to_dataclass({}, unwrapped)
    return None

I kept the "all fields optional" guard so a dataclass with required fields (e.g. one that would need TypeError-raising positional args) is left as None instead of crashing — an absent optional section stays unset. Added regression tests for the null-valued metric console: exporter and for the required-field guard. PTAL.

…ry#5451)

Adopt @emdneto's suggestion to substitute an empty mapping and let the
existing dict/dataclass conversion path build it, instead of a separate
coercion helper.

Keep an "all fields optional" guard (_is_empty_constructible_dataclass):
21 config dataclasses have required fields (e.g. BatchSpanProcessor.exporter,
PeriodicMetricReader.exporter), and coercing a present null into those via
cls() would raise TypeError. Leaving them None routes to the existing
"Unsupported type" ConfigurationError instead.
@bourbonkk

Copy link
Copy Markdown
Contributor Author

Adopted the structure — substitute {} and let the existing dict/dataclass conversion below build it, rather than a separate helper (c8da534).

I kept one guard on top of it: only coerce when the dataclass is constructible with no arguments. 21 config dataclasses have a required field — e.g. BatchSpanProcessor.exporter, PeriodicMetricReader.exporter, SimpleSpanProcessor.exporter, View.selector. For those, value = {}_dict_to_dataclass({}, cls)cls() raises TypeError on the missing positional arg. So e.g.:

processors:
  - batch:

would crash instead of producing the existing clean ConfigurationError: Unsupported ... type. Leaving required-field dataclasses as None keeps them on that error path, while console: and friends (all-optional) still get coerced. So it's:

if origin is dict or _is_empty_constructible_dataclass(unwrapped):
    value = {}
else:
    return None

Happy to drop the guard if you'd prefer required-field nodes to error too, but a raw TypeError seemed worse than the current ConfigurationError. PTAL.

@bourbonkk
bourbonkk requested a review from emdneto July 24, 2026 00:31
@emdneto

emdneto commented Jul 24, 2026

Copy link
Copy Markdown
Member

Adopted the structure — substitute {} and let the existing dict/dataclass conversion below build it, rather than a separate helper (c8da534).

I kept one guard on top of it: only coerce when the dataclass is constructible with no arguments. 21 config dataclasses have a required field — e.g. BatchSpanProcessor.exporter, PeriodicMetricReader.exporter, SimpleSpanProcessor.exporter, View.selector. For those, value = {}_dict_to_dataclass({}, cls)cls() raises TypeError on the missing positional arg. So e.g.:

processors:
  - batch:

would crash instead of producing the existing clean ConfigurationError: Unsupported ... type. Leaving required-field dataclasses as None keeps them on that error path, while console: and friends (all-optional) still get coerced. So it's:

if origin is dict or _is_empty_constructible_dataclass(unwrapped):
    value = {}
else:
    return None

Happy to drop the guard if you'd prefer required-field nodes to error too, but a raw TypeError seemed worse than the current ConfigurationError. PTAL.

I don't get your point here.

If I leave the batch configuration empty, it will trigger a ConfigurationError as expected.
e.g.,

opentelemetry.configuration._exceptions.ConfigurationError: Configuration does not match schema: None is not of type 'object' (at logger_provider -> processors -> 1 -> batch)

…emetry#5451)

Replace the synthetic required-field dataclass fixture with the one real
node that is schema-nullable yet has required fields:
ExperimentalJaegerRemoteSampler (endpoint, initial_sampler).

Unlike batch/periodic (schema type: object, so a null is rejected before
conversion), jaeger_remote_development is schema type: [object, null], so
jaeger_remote_development: passes validation and reaches the conversion
layer. Coercing it would raise TypeError; the guard leaves it None.

Adds an end-to-end loader test proving the schema accepts the null and
conversion survives, alongside a sibling console: node that is still
coerced to {}.
@bourbonkk

bourbonkk commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@emdneto
You're right about batch/periodic — my examples were wrong. Those are type: object (non-nullable) in the schema, so - batch: is rejected at schema validation (None is not of type 'object') and never reaches conversion. So the guard is irrelevant for them.

I cross-checked all 21 required-field config dataclasses against the schema, and there's exactly one that is schema-nullable (type: [object, null]) and has required fields: ExperimentalJaegerRemoteSampler (endpoint, initial_sampler). So:

tracer_provider:
  sampler:
    jaeger_remote_development:

passes schema validation (it IS nullable), reaches conversion, and without the guard _dict_to_dataclass({}, ExperimentalJaegerRemoteSampler)cls() raises:

TypeError: ExperimentalJaegerRemoteSampler.__init__() missing 2 required positional arguments: 'endpoint' and 'initial_sampler'

The guard leaves it None instead. I've added an end-to-end loader test (test_null_valued_required_field_node_survives_conversion) that loads exactly this config to prove the schema accepts the null and conversion survives, and switched the conversion unit test to use this real node instead of a synthetic one.

If you'd rather not carry the guard for a single experimental sampler, I'm happy to drop it and instead flag the schema inconsistency upstream (a nullable node whose object form has required properties). Let me know which you prefer.

@xrmx
xrmx self-requested a review July 24, 2026 08:37
Comment thread opentelemetry-configuration/tests/file/test_loader.py Outdated
Address review: instead of re-implementing the tempfile write/load/unlink
dance in the new test, give TestConfigLoaderEndToEnd._load an optional yaml
parameter (defaulting to the class _YAML) and pass the jaeger config to it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Reviewed PRs that need fixes

Development

Successfully merging this pull request may close these issues.

opentelemetry-configuration: issue in parsing sampler configuration

3 participants