Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ Added
0.64b0): it ships a curated, validated set of versions rather than a range,
and CI now fails if a non-exact constraint is reintroduced
(`#7 <https://github.com/dash0hq/opentelemetry-python-distribution/issues/7>`_).
- ``declarative-config`` extra installing the dependencies for declarative
(file-based) configuration via ``OTEL_CONFIG_FILE``; when they are missing,
the configurator now logs one actionable error naming the package to install
instead of failing with a traceback and silently sending no telemetry
(`#5 <https://github.com/dash0hq/opentelemetry-python-distribution/issues/5>`_).
21 changes: 17 additions & 4 deletions packages/dash0-opentelemetry-distro/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,28 @@ Ported from the Node.js distribution's custom detectors:

Existing attributes are never overridden.

Declarative configuration
=========================

When ``OTEL_CONFIG_FILE`` points at a declarative configuration file, the SDK is
built from that file instead of from the environment variables above (the
distro's exporter defaults and detected resource attributes do not apply; the
bootstrap span and signal flushing still do). The declarative loader needs
dependencies the SDK does not pull in by default — install the distribution as
``dash0-opentelemetry-distro[declarative-config]``. Without them, the
configurator logs a single actionable error naming the missing package and the
process runs without telemetry.

Dependency policy
=================

The distribution ships a curated set of upstream OpenTelemetry packages, pinned
exactly in its ``pyproject.toml``: each release is validated against precisely
those versions, and the code assumes them. Version bumps are deliberate,
standalone changes. CI enforces the policy via
``scripts/check_pinned_dependencies.py`` (in-repo workspace members are exempt,
as their version is fixed by the checkout).
those versions, and the code assumes them (for example, the
declarative-configuration preflight matches where the pinned SDK keeps the
loader). Version bumps are deliberate, standalone changes. CI enforces the
policy via ``scripts/check_pinned_dependencies.py`` (in-repo workspace members
are exempt, as their version is fixed by the checkout).

Environment variables
=====================
Expand Down
10 changes: 9 additions & 1 deletion packages/dash0-opentelemetry-distro/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ classifiers = [
]
# The distribution ships a curated, exactly-pinned set of upstream packages:
# every release is validated against precisely these versions, and the code
# assumes them. Bump them together, deliberately, in a dedicated change.
# (e.g. the declarative-configuration preflight in configurator.py) assumes
# them. Bump them together, deliberately, in a dedicated change.
dependencies = [
"opentelemetry-api == 1.43.0",
"opentelemetry-sdk == 1.43.0",
Expand All @@ -35,6 +36,13 @@ dependencies = [
"opentelemetry-exporter-otlp-pyproto-http",
]

[project.optional-dependencies]
# Dependencies for declarative (file-based) configuration via OTEL_CONFIG_FILE.
# The pinned SDK line bundles the loader behind this extra (pyyaml, jsonschema).
# Note for the next SDK bump: from 1.44 the loader moves to the separate
# opentelemetry-configuration package, which replaces this extra.
declarative-config = ["opentelemetry-sdk[file-configuration]"]

[project.entry-points.opentelemetry_distro]
dash0 = "dash0.opentelemetry.distro:Dash0Distro"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@
OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"
OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES"
OTEL_SDK_DISABLED = "OTEL_SDK_DISABLED"
OTEL_CONFIG_FILE = "OTEL_CONFIG_FILE"
"""Declarative (file-based) SDK configuration. Read by the SDK's configurator;
the distribution only checks it to fail loudly when the required
declarative-configuration dependencies are missing."""
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
Registered as the single ``opentelemetry_configurator`` entry point. It runs
after :class:`~dash0.opentelemetry.distro.Dash0Distro` has set the environment,
so it lets the standard OpenTelemetry SDK configurator build the providers,
exporters and Resource from that environment, then adds two Dash0 behaviors from
the Node.js distribution: an optional startup ("bootstrap") span and optional
graceful flushing on SIGTERM/SIGINT.
exporters and Resource from that environment (or from a declarative
configuration file when ``OTEL_CONFIG_FILE`` is set), then adds two Dash0
behaviors from the Node.js distribution: an optional startup ("bootstrap") span
and optional graceful flushing on SIGTERM/SIGINT.

Normal-exit flushing is already handled by the SDK: the tracer/meter/logger
providers register ``atexit`` shutdown hooks. The signal handling below covers
Expand All @@ -22,12 +23,34 @@
from ._environment_variables import (
DASH0_BOOTSTRAP_SPAN,
DASH0_FLUSH_ON_SIGTERM_SIGINT,
OTEL_CONFIG_FILE,
)
from .settings import evaluate_gate, is_true

_logger = getLogger(__name__)


def _declarative_config_import_error():
"""Return why declarative configuration cannot be loaded, or ``None``.

The SDK version this distribution pins bundles the ``OTEL_CONFIG_FILE``
loader in ``opentelemetry.sdk._configuration.file`` behind the
``opentelemetry-sdk[file-configuration]`` extra (pyyaml, jsonschema), which
a plain install does not pull in. Without this preflight the SDK
configurator fails with a raw traceback and the process runs without any
telemetry. Revisit when bumping the pinned SDK: from 1.44 the loader lives
in the separate ``opentelemetry-configuration`` package instead.
"""
try:
# pylint: disable=import-outside-toplevel,unused-import
import opentelemetry.sdk._configuration.file._loader # noqa: F401

return None
except ImportError as error:
# The loader's own message names the extra to install.
return str(error)


class Dash0Configurator(_OTelSDKConfigurator):
"""Configure the OpenTelemetry SDK for the Dash0 distribution."""

Expand All @@ -40,6 +63,18 @@ def _configure(self, **kwargs):
)
return

if environ.get(OTEL_CONFIG_FILE):
import_error = _declarative_config_import_error()
if import_error:
_logger.error(
"%s is set but the declarative-configuration dependencies "
"are not installed: %s. The SDK will not be configured and "
"no telemetry will be sent.",
OTEL_CONFIG_FILE,
import_error,
)
return

super()._configure(**kwargs)

bootstrap_span_name = environ.get(DASH0_BOOTSTRAP_SPAN)
Expand Down
102 changes: 102 additions & 0 deletions packages/dash0-opentelemetry-distro/tests/test_configurator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import logging

import pytest
from dash0.opentelemetry import configurator as configurator_module
from dash0.opentelemetry._environment_variables import (
DASH0_BOOTSTRAP_SPAN,
DASH0_FLUSH_ON_SIGTERM_SIGINT,
DASH0_OTEL_COLLECTOR_BASE_URL,
OTEL_CONFIG_FILE,
)
from dash0.opentelemetry.configurator import (
Dash0Configurator,
_declarative_config_import_error,
)
from opentelemetry.sdk._configuration import _OTelSDKConfigurator

_MANAGED_VARS = (
DASH0_BOOTSTRAP_SPAN,
DASH0_FLUSH_ON_SIGTERM_SIGINT,
DASH0_OTEL_COLLECTOR_BASE_URL,
OTEL_CONFIG_FILE,
)


@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for variable in _MANAGED_VARS:
monkeypatch.delenv(variable, raising=False)


@pytest.fixture
def _record_sdk_configure(monkeypatch):
calls = []
monkeypatch.setattr(
_OTelSDKConfigurator,
"_configure",
lambda self, **kwargs: calls.append(kwargs),
)
return calls


def test_configure_skips_sdk_when_declarative_config_unavailable(
monkeypatch, caplog, _record_sdk_configure
):
monkeypatch.setenv(DASH0_OTEL_COLLECTOR_BASE_URL, "http://collector:4318")
monkeypatch.setenv(OTEL_CONFIG_FILE, "/etc/otel/config.yaml")
monkeypatch.setattr(
configurator_module,
"_declarative_config_import_error",
lambda: "declarative configuration requires pyyaml",
)

with caplog.at_level(logging.ERROR, logger=configurator_module.__name__):
Dash0Configurator().configure()

# One actionable error instead of a traceback, and no half-configured SDK.
assert not _record_sdk_configure
assert any(
OTEL_CONFIG_FILE in record.getMessage() and "pyyaml" in record.getMessage()
for record in caplog.records
)


def test_configure_uses_sdk_configurator_when_declarative_config_available(
monkeypatch, _record_sdk_configure
):
monkeypatch.setenv(DASH0_OTEL_COLLECTOR_BASE_URL, "http://collector:4318")
monkeypatch.setenv(OTEL_CONFIG_FILE, "/etc/otel/config.yaml")
monkeypatch.setattr(
configurator_module,
"_declarative_config_import_error",
lambda: None,
)

Dash0Configurator().configure()

assert len(_record_sdk_configure) == 1


def test_configure_without_config_file_never_checks_imports(
monkeypatch, _record_sdk_configure
):
monkeypatch.setenv(DASH0_OTEL_COLLECTOR_BASE_URL, "http://collector:4318")

def _must_not_run():
raise AssertionError("import preflight ran without OTEL_CONFIG_FILE")

monkeypatch.setattr(
configurator_module, "_declarative_config_import_error", _must_not_run
)

Dash0Configurator().configure()

assert len(_record_sdk_configure) == 1


def test_declarative_config_import_error_is_none_or_actionable():
# Environment-dependent: None when the declarative-configuration
# dependencies are installed, otherwise a message naming what to install.
result = _declarative_config_import_error()

assert result is None or "install" in result.lower()
Loading
Loading