Skip to content

Commit 063809f

Browse files
ocelotlMikeGoldsmithlzchen
authored
opentelemetry-sdk: activate instrumentors from declarative config (#5372)
* opentelemetry-sdk: implement instrumentation.python declarative config support Add configure_instrumentation() to activate instrumentors listed under instrumentation/development.python in a declarative config file. Each key is resolved via the opentelemetry_instrumentor entry point group and instrument(**opts) is called with the supplied options. An enabled: false value suppresses a library without raising; unknown entry points and instrumentor failures are logged without stopping the others. Wire configure_instrumentation() into configure_sdk() so the python section is applied alongside providers on the declarative config path. Fixes #5361 Assisted-by: Claude Sonnet 4.6 * opentelemetry-sdk: normalize slash in YAML keys during dict-to-dataclass conversion Schema keys like "otlp_file/development" use "/" as a namespace separator for development/experimental features. Python dataclass field names cannot contain "/" so they use "_" (e.g. "otlp_file_development"). Without normalization, the key falls through to additional_properties and triggers an entry-point lookup for "otlp_file/development" (which doesn't exist), rather than using the dedicated _create_otlp_file_development_span_exporter factory function. * opentelemetry-sdk: validate instrumentation opts via config_dataclass If an instrumentor class exposes a config_dataclass attribute, the raw YAML options are run through _dict_to_dataclass before instrument() is called. This reuses the same type-coercion pipeline used for SDK component configuration so instrumentors declare a typed dataclass schema and the SDK handles the rest. Instrumentors without config_dataclass continue to receive options unchanged, preserving backwards compatibility. * refactor: inline _coerce_opts into configure_instrumentation * refactor: rename _instrumentation to instrumentation, use from-imports, expand slash-normalization comment * changelog: add fragment for PR 5372 * fix(ci): fix pylint and ruff failures in instrumentation files - Rename walrus variable `v` to `value` (pylint C0103) - Add pylint disable=no-self-use at class level in test file (pylint R6301) - Move `instrumentation` import after `_tracer_provider` in _sdk.py (ruff I001) - Convert `import logging/dataclasses/unittest` to from-imports - Apply ruff format to reformatted lines * fix(ci): move pylint disable inside class body so it applies to methods * refactor: rename config_dataclass attribute to configuration * ci: retrigger * fix: address review comments on instrumentation declarative config - Add inspect.isclass + is_dataclass guard before using the configuration attribute, so non-dataclass values are silently ignored - Skip instrument() when the instrumentor is already active to avoid duplicate-instrumentation warnings when opentelemetry-instrument and declarative config are both in play - Add Instrumentation section to docs/sdk/configuration.rst with instrumentation/development.python YAML example - Add instrumentation/development example to the declarative-config example otel-config.yaml and README * fix: use from inspect import isclass instead of import inspect --------- Co-authored-by: Mike Goldsmith <goldsmith.mike@gmail.com> Co-authored-by: Leighton Chen <lechen@microsoft.com>
1 parent 08322fd commit 063809f

8 files changed

Lines changed: 395 additions & 23 deletions

File tree

.changelog/5372.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: Add support for activating instrumentors from a declarative configuration file via the `instrumentation/development.python` section. Instrumentors can declare a `configuration` attribute to have their options validated through the same type-coercion pipeline used for SDK component configuration.

docs/examples/declarative-config/README.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ The source files of this example are available :scm_web:`here
1616
<docs/examples/declarative-config/>`.
1717

1818
Install the SDK with the ``file-configuration`` extra (it pulls in ``pyyaml``
19-
and ``jsonschema``), the auto-instrumentation entry point, and the OTLP/HTTP
20-
exporter:
19+
and ``jsonschema``), the auto-instrumentation entry point, the OTLP/HTTP
20+
exporter, and the ``requests`` instrumentation used by this example:
2121

2222
.. code-block:: sh
2323
2424
pip install "opentelemetry-sdk[file-configuration]" \
2525
opentelemetry-distro \
26-
opentelemetry-exporter-otlp-proto-http
26+
opentelemetry-exporter-otlp-proto-http \
27+
opentelemetry-instrumentation-requests
2728
2829
Start an OTLP-capable backend locally so there is somewhere to send data. Write
2930
the following file:
@@ -74,7 +75,10 @@ auto-instrumentation apply it. No configuration code lives in ``app.py``:
7475
export OTEL_CONFIG_FILE=$(pwd)/otel-config.yaml
7576
opentelemetry-instrument python app.py
7677
77-
You should see the exported span in the Collector's debug output.
78+
You should see the exported span in the Collector's debug output. The
79+
``instrumentation/development.python`` section in ``otel-config.yaml``
80+
activates the ``requests`` instrumentation so outgoing HTTP calls made by
81+
``app.py`` are automatically traced without any code changes.
7882

7983
Environment variable substitution
8084
----------------------------------

docs/examples/declarative-config/otel-config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ logger_provider:
3232
exporter:
3333
otlp_http:
3434
endpoint: http://localhost:4318/v1/logs
35+
36+
instrumentation/development:
37+
python:
38+
requests:
39+
enabled: true

docs/sdk/configuration.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,31 @@ OTLP/HTTP. The source is available :scm_web:`here
9595
- name: api-key
9696
value: ${OTLP_API_KEY}
9797
98+
Instrumentation
99+
---------------
100+
101+
The ``instrumentation/development.python`` section activates Python
102+
instrumentors by their ``opentelemetry_instrumentor`` entry-point name. Set
103+
``enabled: false`` to suppress an instrumentor without removing its entry, and
104+
pass any other keys as keyword arguments to ``instrument()``:
105+
106+
.. code-block:: yaml
107+
108+
instrumentation/development:
109+
python:
110+
requests:
111+
enabled: true
112+
urllib3:
113+
enabled: true
114+
max_spans_per_request: 10
115+
116+
If the instrumentor class declares a ``configuration`` class attribute pointing
117+
to a dataclass, the options are validated and type-coerced through the same
118+
pipeline used for SDK component configuration before being forwarded to
119+
``instrument()``. Instrumentors that are already active (for example because
120+
``opentelemetry-instrument`` ran before the file was applied) are silently
121+
skipped.
122+
98123
Environment variable substitution
99124
----------------------------------
100125

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@
1111

1212
from __future__ import annotations
1313

14-
import dataclasses
15-
import enum
16-
import types
17-
import typing
1814
from collections.abc import Mapping
19-
from typing import Any, TypeVar, get_args, get_origin
15+
from dataclasses import fields, is_dataclass
16+
from enum import Enum
17+
from types import UnionType
18+
from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints
2019

2120
_T = TypeVar("_T")
2221

@@ -27,7 +26,7 @@ def _unwrap_optional(type_hint: Any) -> Any:
2726
Returns the unwrapped type, or the original hint if not a union with None.
2827
"""
2928
origin = get_origin(type_hint)
30-
if origin is types.UnionType or origin is typing.Union:
29+
if origin is UnionType or origin is Union:
3130
non_none = [t for t in get_args(type_hint) if t is not type(None)]
3231
if len(non_none) == 1:
3332
return non_none[0]
@@ -58,15 +57,15 @@ def _convert_value(value: Any, type_hint: Any) -> Any:
5857
# Direct dataclass type — recurse
5958
if (
6059
isinstance(unwrapped, type)
61-
and dataclasses.is_dataclass(unwrapped)
60+
and is_dataclass(unwrapped)
6261
and isinstance(value, dict)
6362
):
6463
return _dict_to_dataclass(value, unwrapped)
6564

6665
# Enum type — coerce string/value to the Enum member
6766
if (
6867
isinstance(unwrapped, type)
69-
and issubclass(unwrapped, enum.Enum)
68+
and issubclass(unwrapped, Enum)
7069
and not isinstance(value, unwrapped)
7170
):
7271
return unwrapped(value)
@@ -90,22 +89,28 @@ def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T:
9089
Raises:
9190
TypeError: If ``cls`` is not a dataclass type.
9291
"""
93-
if not dataclasses.is_dataclass(cls):
92+
if not is_dataclass(cls):
9493
raise TypeError(f"{cls.__name__} is not a dataclass")
9594

9695
# Annotated as ``dict[str, Any]`` so astroid stops tracing into
97-
# ``typing.get_type_hints`` — under pylint 3.x that path leads into
96+
# ``get_type_hints`` — under pylint 3.x that path leads into
9897
# Python 3.14's ``annotationlib`` (which uses t-strings) and crashes.
99-
hints: dict[str, Any] = dict(
100-
typing.get_type_hints(cls, include_extras=False)
101-
)
102-
known_fields = {f.name for f in dataclasses.fields(cls)}
98+
hints: dict[str, Any] = dict(get_type_hints(cls, include_extras=False))
99+
known_fields = {f.name for f in fields(cls)}
103100
kwargs: dict[str, Any] = {}
104101

105102
for key, value in data.items():
106-
if key in known_fields:
107-
type_hint = hints.get(key)
108-
kwargs[key] = _convert_value(value, type_hint)
103+
# The OTel configuration schema uses "/" as a namespace separator for
104+
# development/experimental features (e.g. "otlp_file/development",
105+
# "instrumentation/development"). Python identifiers cannot contain
106+
# "/", so the corresponding dataclass fields use "_" instead (e.g.
107+
# "otlp_file_development"). Without this normalisation the key would
108+
# not match any known field and would fall through to
109+
# additional_properties, causing the factory lookup to fail silently.
110+
field_key = key.replace("/", "_")
111+
if field_key in known_fields:
112+
type_hint = hints.get(field_key)
113+
kwargs[field_key] = _convert_value(value, type_hint)
109114
else:
110115
# Unknown key — @_additional_properties decorator will capture it.
111116
kwargs[key] = value

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from __future__ import annotations
1212

13-
import logging
13+
from logging import getLogger
1414

1515
from opentelemetry.sdk._configuration._logger_provider import (
1616
configure_logger_provider,
@@ -23,9 +23,12 @@
2323
from opentelemetry.sdk._configuration._tracer_provider import (
2424
configure_tracer_provider,
2525
)
26+
from opentelemetry.sdk._configuration.instrumentation import (
27+
configure_instrumentation,
28+
)
2629
from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration
2730

28-
_logger = logging.getLogger(__name__)
31+
_logger = getLogger(__name__)
2932

3033

3134
def configure_sdk(config: OpenTelemetryConfiguration) -> None:
@@ -62,3 +65,4 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None:
6265
configure_meter_provider(config.meter_provider, resource)
6366
configure_logger_provider(config.logger_provider, resource)
6467
configure_propagator(config.propagator)
68+
configure_instrumentation(config.instrumentation_development)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
from dataclasses import fields, is_dataclass
7+
from inspect import isclass
8+
from logging import getLogger
9+
10+
from opentelemetry.sdk._configuration._common import load_entry_point
11+
from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass
12+
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
13+
from opentelemetry.sdk._configuration.models import ExperimentalInstrumentation
14+
15+
_logger = getLogger(__name__)
16+
17+
18+
def configure_instrumentation(
19+
configuration: ExperimentalInstrumentation | None,
20+
) -> None:
21+
"""Activate instrumentors listed under ``instrumentation/development.python``.
22+
23+
For each entry in ``configuration.python`` the matching
24+
``opentelemetry_instrumentor`` entry point is loaded. If the instrumentor
25+
class exposes a ``configuration`` attribute that is a dataclass type, the
26+
raw options are validated through ``_dict_to_dataclass`` before being
27+
forwarded to ``instrument()``. An ``enabled: false`` value suppresses
28+
instrumentation without raising.
29+
30+
If an instrumentor is already active (e.g. ``opentelemetry-instrument``
31+
ran before the SDK was configured from the file) its ``instrument()`` call
32+
is skipped to avoid a double-instrumentation warning.
33+
34+
Absent or unknown entry points are logged as warnings; runtime errors from
35+
an instrumentor are logged as exceptions. Neither stops the remaining
36+
instrumentors from being applied.
37+
"""
38+
if configuration is None or configuration.python is None:
39+
return
40+
41+
for name, options in configuration.python.items():
42+
options = dict(options)
43+
if not options.pop("enabled", True):
44+
_logger.debug(
45+
"Instrumentation '%s' is disabled in declarative config; skipping",
46+
name,
47+
)
48+
continue
49+
50+
try:
51+
cls = load_entry_point("opentelemetry_instrumentor", name)
52+
configuration_cls = getattr(cls, "configuration", None)
53+
if isclass(configuration_cls) and is_dataclass(configuration_cls):
54+
configuration_obj = _dict_to_dataclass(
55+
options, configuration_cls
56+
)
57+
options = {
58+
f.name: value
59+
for f in fields(configuration_obj)
60+
if (value := getattr(configuration_obj, f.name))
61+
is not None
62+
}
63+
instance = cls()
64+
if getattr(instance, "is_instrumented_by_opentelemetry", False):
65+
_logger.debug("Skipping '%s': already instrumented", name)
66+
else:
67+
instance.instrument(**options)
68+
_logger.debug("Instrumented '%s' via declarative config", name)
69+
except ConfigurationError as exc:
70+
_logger.warning(
71+
"Skipping instrumentation '%s' in declarative config: %s",
72+
name,
73+
exc,
74+
)
75+
except Exception: # pylint: disable=broad-except
76+
_logger.exception(
77+
"Failed to instrument '%s' via declarative config", name
78+
)

0 commit comments

Comments
 (0)