Skip to content
Open
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
1 change: 1 addition & 0 deletions .changelog/5372.added
Original file line number Diff line number Diff line change
@@ -0,0 +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.
12 changes: 8 additions & 4 deletions docs/examples/declarative-config/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ The source files of this example are available :scm_web:`here
<docs/examples/declarative-config/>`.

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

.. code-block:: sh

pip install "opentelemetry-sdk[file-configuration]" \
opentelemetry-distro \
opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-requests

Start an OTLP-capable backend locally so there is somewhere to send data. Write
the following file:
Expand Down Expand Up @@ -74,7 +75,10 @@ auto-instrumentation apply it. No configuration code lives in ``app.py``:
export OTEL_CONFIG_FILE=$(pwd)/otel-config.yaml
opentelemetry-instrument python app.py

You should see the exported span in the Collector's debug output.
You should see the exported span in the Collector's debug output. The
``instrumentation/development.python`` section in ``otel-config.yaml``
activates the ``requests`` instrumentation so outgoing HTTP calls made by
``app.py`` are automatically traced without any code changes.

Environment variable substitution
----------------------------------
Expand Down
5 changes: 5 additions & 0 deletions docs/examples/declarative-config/otel-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ logger_provider:
exporter:
otlp_http:
endpoint: http://localhost:4318/v1/logs

instrumentation/development:
python:
requests:
enabled: true
25 changes: 25 additions & 0 deletions docs/sdk/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ OTLP/HTTP. The source is available :scm_web:`here
- name: api-key
value: ${OTLP_API_KEY}

Instrumentation
---------------

The ``instrumentation/development.python`` section activates Python
instrumentors by their ``opentelemetry_instrumentor`` entry-point name. Set
``enabled: false`` to suppress an instrumentor without removing its entry, and
pass any other keys as keyword arguments to ``instrument()``:

.. code-block:: yaml

instrumentation/development:
python:
requests:
enabled: true
urllib3:
enabled: true
max_spans_per_request: 10

If the instrumentor class declares a ``configuration`` class attribute pointing
to a dataclass, the options are validated and type-coerced through the same
pipeline used for SDK component configuration before being forwarded to
``instrument()``. Instrumentors that are already active (for example because
``opentelemetry-instrument`` ran before the file was applied) are silently
skipped.

Environment variable substitution
----------------------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@

from __future__ import annotations

import dataclasses
import enum
import types
import typing
from collections.abc import Mapping
from typing import Any, TypeVar, get_args, get_origin
from dataclasses import fields, is_dataclass
from enum import Enum
from types import UnionType
from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints

_T = TypeVar("_T")

Expand All @@ -27,7 +26,7 @@ def _unwrap_optional(type_hint: Any) -> Any:
Returns the unwrapped type, or the original hint if not a union with None.
"""
origin = get_origin(type_hint)
if origin is types.UnionType or origin is typing.Union:
if origin is UnionType or origin is Union:
non_none = [t for t in get_args(type_hint) if t is not type(None)]
if len(non_none) == 1:
return non_none[0]
Expand Down Expand Up @@ -58,15 +57,15 @@ def _convert_value(value: Any, type_hint: Any) -> Any:
# Direct dataclass type — recurse
if (
isinstance(unwrapped, type)
and dataclasses.is_dataclass(unwrapped)
and is_dataclass(unwrapped)
and isinstance(value, dict)
):
return _dict_to_dataclass(value, unwrapped)

# Enum type — coerce string/value to the Enum member
if (
isinstance(unwrapped, type)
and issubclass(unwrapped, enum.Enum)
and issubclass(unwrapped, Enum)
and not isinstance(value, unwrapped)
):
return unwrapped(value)
Expand All @@ -90,22 +89,28 @@ def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T:
Raises:
TypeError: If ``cls`` is not a dataclass type.
"""
if not dataclasses.is_dataclass(cls):
if not is_dataclass(cls):
raise TypeError(f"{cls.__name__} is not a dataclass")

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

for key, value in data.items():
if key in known_fields:
type_hint = hints.get(key)
kwargs[key] = _convert_value(value, type_hint)
# The OTel configuration schema uses "/" as a namespace separator for
# development/experimental features (e.g. "otlp_file/development",
# "instrumentation/development"). Python identifiers cannot contain
# "/", so the corresponding dataclass fields use "_" instead (e.g.
# "otlp_file_development"). Without this normalisation the key would
# not match any known field and would fall through to
# additional_properties, causing the factory lookup to fail silently.
field_key = key.replace("/", "_")
if field_key in known_fields:
type_hint = hints.get(field_key)
kwargs[field_key] = _convert_value(value, type_hint)
else:
# Unknown key — @_additional_properties decorator will capture it.
kwargs[key] = value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from __future__ import annotations

import logging
from logging import getLogger

from opentelemetry.sdk._configuration._logger_provider import (
configure_logger_provider,
Expand All @@ -23,9 +23,12 @@
from opentelemetry.sdk._configuration._tracer_provider import (
configure_tracer_provider,
)
from opentelemetry.sdk._configuration.instrumentation import (
configure_instrumentation,
)
from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration

_logger = logging.getLogger(__name__)
_logger = getLogger(__name__)


def configure_sdk(config: OpenTelemetryConfiguration) -> None:
Expand Down Expand Up @@ -62,3 +65,4 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None:
configure_meter_provider(config.meter_provider, resource)
configure_logger_provider(config.logger_provider, resource)
configure_propagator(config.propagator)
configure_instrumentation(config.instrumentation_development)
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

from dataclasses import fields, is_dataclass
from inspect import isclass
from logging import getLogger

from opentelemetry.sdk._configuration._common import load_entry_point
from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
from opentelemetry.sdk._configuration.models import ExperimentalInstrumentation

_logger = getLogger(__name__)


def configure_instrumentation(
configuration: ExperimentalInstrumentation | None,
) -> None:
"""Activate instrumentors listed under ``instrumentation/development.python``.
Comment thread
ocelotl marked this conversation as resolved.

For each entry in ``configuration.python`` the matching
``opentelemetry_instrumentor`` entry point is loaded. If the instrumentor
class exposes a ``configuration`` attribute that is a dataclass type, the
raw options are validated through ``_dict_to_dataclass`` before being
forwarded to ``instrument()``. An ``enabled: false`` value suppresses
instrumentation without raising.

If an instrumentor is already active (e.g. ``opentelemetry-instrument``
ran before the SDK was configured from the file) its ``instrument()`` call
is skipped to avoid a double-instrumentation warning.

Absent or unknown entry points are logged as warnings; runtime errors from
an instrumentor are logged as exceptions. Neither stops the remaining
instrumentors from being applied.
"""
if configuration is None or configuration.python is None:
return

for name, options in configuration.python.items():
options = dict(options)
if not options.pop("enabled", True):
_logger.debug(
"Instrumentation '%s' is disabled in declarative config; skipping",
name,
)
continue

try:
cls = load_entry_point("opentelemetry_instrumentor", name)
configuration_cls = getattr(cls, "configuration", None)
if isclass(configuration_cls) and is_dataclass(configuration_cls):
configuration_obj = _dict_to_dataclass(
options, configuration_cls
)
options = {
f.name: value
for f in fields(configuration_obj)
if (value := getattr(configuration_obj, f.name))
is not None
}
instance = cls()
if getattr(instance, "is_instrumented_by_opentelemetry", False):
_logger.debug("Skipping '%s': already instrumented", name)
else:
instance.instrument(**options)
_logger.debug("Instrumented '%s' via declarative config", name)
except ConfigurationError as exc:
_logger.warning(
"Skipping instrumentation '%s' in declarative config: %s",
name,
exc,
)
except Exception: # pylint: disable=broad-except
_logger.exception(
"Failed to instrument '%s' via declarative config", name
)
Loading
Loading