-
Notifications
You must be signed in to change notification settings - Fork 932
opentelemetry-sdk: activate instrumentors from declarative config #5372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5f72ea5
8b37ec5
58488d2
bc14db0
a2a88df
5d54326
08b992f
72d74b7
d58e2f1
76ab381
b58e602
1d6fa79
b6bf5e0
8b91240
5f5deb4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
|
||
|
|
@@ -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] | ||
|
|
@@ -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) | ||
|
|
@@ -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("/", "_") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't this the very same change of the @emdneto PR?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ocelotl care to add some collision check? |
||
| 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 | ||
|
|
||
| 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``. | ||
|
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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this method regarding the internals necessary? Maybe the behavior can be succinctly described instead? |
||
| 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): | ||
|
MikeGoldsmith marked this conversation as resolved.
|
||
| _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 | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.