Skip to content
Merged
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 datadog_checks_base/changelog.d/22750.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add file-based YAML metrics loading for OpenMetrics V2 checks with composable predicates
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# (C) Datadog, Inc. 2020-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
import lazy_loader

__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# (C) Datadog, Inc. 2025-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from .base import OpenMetricsBaseCheckV2
from .metrics_mapping import (
AllOf,
AnyOf,
ConfigOptionEquals,
ConfigOptionTruthy,
MetricsMapping,
MetricsPredicate,
)

__all__ = [
'AllOf',
'AnyOf',
'ConfigOptionEquals',
'ConfigOptionTruthy',
'MetricsMapping',
'MetricsPredicate',
'OpenMetricsBaseCheckV2',
]
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# (C) Datadog, Inc. 2020-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from __future__ import annotations

from collections import ChainMap
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING

import yaml
from requests.exceptions import RequestException

from datadog_checks.base.checks import AgentCheck
Expand All @@ -12,6 +17,11 @@

from .scraper import OpenMetricsScraper

if TYPE_CHECKING:
from collections.abc import Mapping

from .metrics_mapping import MetricsMapping, _RawMetricsConfig


class OpenMetricsBaseCheckV2(AgentCheck):
"""
Expand All @@ -32,6 +42,14 @@ class OpenMetricsBaseCheckV2(AgentCheck):

DEFAULT_METRIC_LIMIT = 2000

METRICS_MAP: tuple[MetricsMapping, ...] = ()
"""YAML files with metric name mappings to load automatically.

When empty (default), looks for ``metrics.yaml`` next to the check module,
falling back to ``metrics.yml`` if the former is absent. When set, only the
declared files are loaded (with predicates controlling conditional loading).
"""

# Allow tracing for openmetrics integrations
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Expand All @@ -52,6 +70,9 @@ def __init__(self, name, init_config, instances):
# All configured scrapers keyed by the endpoint
self.scrapers = {}

# Cache for file-based metrics loaded from METRICS_MAP; None means not yet loaded
self._file_metrics: list[_RawMetricsConfig] | None = None

self.check_initializations.append(self.configure_scrapers)

def check(self, _):
Expand Down Expand Up @@ -105,14 +126,80 @@ def set_dynamic_tags(self, *tags):
scraper.set_dynamic_tags(*tags)

def get_config_with_defaults(self, config):
return ChainMap(config, self.get_default_config())
"""Combine instance config with class defaults and file-based metric mappings.

Subclasses that override this method must call ``super().get_config_with_defaults(config)``;
otherwise the YAML mappings declared via ``METRICS_MAP`` (or discovered by convention) are
silently skipped.
"""
defaults = dict(self.get_default_config())
if file_metrics := self._load_file_based_metrics(config):
defaults['metrics'] = list(defaults.get('metrics', [])) + file_metrics
return ChainMap(config, defaults)

def get_default_config(self):
def get_default_config(self) -> dict:
"""Return instance-level default scraper configuration values.

The returned dict can be mutated by the framework before being wrapped
in a ``ChainMap``. Avoid returning a shared or instance-level object to avoid
state leakage between check executions.
"""
return {}

def refresh_scrapers(self):
pass

def _load_file_based_metrics(self, config: Mapping) -> list[_RawMetricsConfig]:
"""Load metric mappings from YAML files declared in ``METRICS_MAP``.

Results are cached for the lifetime of the check instance. Predicates
are evaluated once against the first ``config`` supplied; ``METRICS_MAP``
is a class-level declaration and the instance config does not change
between runs, so subsequent calls always receive the same effective
configuration.

Falls back to convention-based discovery of ``metrics.yaml`` or
``metrics.yml`` (in that order) when ``METRICS_MAP`` is empty.

Permanent load failures (malformed YAML, unreadable files) are raised
once on the first call; the cache is sealed beforehand so subsequent
scrapes do not retry and re-raise the same error. A failure on any
single file in a multi-file ``METRICS_MAP`` discards results from
files loaded earlier in the same call: the cache lands as ``[]``, not
as a partial mapping.
"""
if self._file_metrics is not None:
return self._file_metrics

self._file_metrics = []
package_dir = self._get_package_dir()
if not self.METRICS_MAP:
for candidate in (Path("metrics.yaml"), Path("metrics.yml")):
resolved = package_dir / candidate
if resolved.is_file():
self._file_metrics = [self._load_metrics_file(resolved)]
break
else:
self._file_metrics = [
self._load_metrics_file(package_dir / source.path)
for source in self.METRICS_MAP
if source.should_load(config)
]

return self._file_metrics

def _load_metrics_file(self, file_path: Path) -> _RawMetricsConfig:
try:
with open(file_path) as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ConfigurationError(f"Failed to parse metrics file {file_path}: {e}") from e
except OSError as e:
raise ConfigurationError(f"Failed to read metrics file {file_path}: {e}") from e
if not isinstance(data, dict):
raise ConfigurationError(f"Metrics file {file_path} must contain a YAML mapping, got {type(data).__name__}")
return data

@contextmanager
def adopt_namespace(self, namespace):
old_namespace = self.__NAMESPACE__
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# (C) Datadog, Inc. 2025-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol

from datadog_checks.base.config import is_affirmative

if TYPE_CHECKING:
from datadog_checks.base.types import InstanceType

_RawMetricsConfig = dict[str, str | dict[str, Any]]
"""Metric name mapping loaded from a YAML file.

Keys are raw Prometheus metric names; values are either Datadog metric names
(simple renaming) or dicts describing transformer configuration. The inner
dict carries the full ``OpenMetricsScraper`` transformer shape (type, label
maps, nested options), so it is intentionally widened to ``dict[str, Any]``.

Internal: the type the loader returns; integration authors should not need to
reference this directly.
"""


class MetricsPredicate(Protocol):
"""
Protocol for predicates that control whether a metrics mapping should be loaded.

Implement ``should_load`` to create custom loading conditions.
"""

def should_load(self, config: InstanceType) -> bool: ...


class ConfigOptionTruthy:
"""
Load metrics only if a configuration option is truthy.

Uses ``is_affirmative`` to evaluate the value. Defaults to ``True``
(include metrics unless explicitly disabled).
"""

def __init__(self, option: str, default: bool = True) -> None:
self.option = option
self.default = default

def should_load(self, config: InstanceType) -> bool:
return is_affirmative(config.get(self.option, self.default))


class ConfigOptionEquals:
"""
Load metrics only if a configuration option equals a specific value.

A missing key compares equal to ``None``: ``ConfigOptionEquals("flag", None)``
matches both ``{"flag": None}`` and instances that omit the key entirely.
"""

def __init__(self, option: str, value: Any) -> None:
self.option = option
self.value = value

def should_load(self, config: InstanceType) -> bool:
return config.get(self.option) == self.value


class AllOf:
"""
Compose predicates: all must pass for the metrics to be loaded.

Follows Python's ``all()`` semantics: returns ``True`` when empty.
"""

def __init__(self, *predicates: MetricsPredicate) -> None:
self.predicates = predicates

def should_load(self, config: InstanceType) -> bool:
return all(p.should_load(config) for p in self.predicates)


class AnyOf:
"""
Compose predicates: any passing is sufficient to load the metrics.

Follows Python's ``any()`` semantics: returns ``False`` when empty.
"""

def __init__(self, *predicates: MetricsPredicate) -> None:
self.predicates = predicates

def should_load(self, config: InstanceType) -> bool:
return any(p.should_load(config) for p in self.predicates)


@dataclass(frozen=True)
class MetricsMapping:
"""
Declares a YAML file with metric name mappings to load automatically.

Use in the ``METRICS_MAP`` class variable of ``OpenMetricsBaseCheckV2``
subclasses. The YAML file should contain a flat mapping of Prometheus
metric names to Datadog metric names::

METRICS_MAP = (
MetricsMapping(Path("metrics/default.yaml")),
MetricsMapping(Path("metrics/go.yaml"), predicate=ConfigOptionTruthy("go_metrics")),
)
"""

path: Path
predicate: MetricsPredicate | None = None

def should_load(self, config: InstanceType) -> bool:
"""Return whether this mapping should be loaded for the given config."""
return self.predicate is None or self.predicate.should_load(config)
Loading
Loading