Skip to content

Commit 93d8da4

Browse files
vitkyrkaclaude
andcommitted
Add configuration discovery infrastructure to datadog_checks_base.
Extends AgentCheck with discover_config / generate_configs class methods for auto-detecting working configurations from service metadata. Adds Discovery, Service, and Port data types to datadog_checks.base.utils.discovery, a probe module (_suppress_discovery_side_effects, _try_discovery_candidate, run_discovery) that trial-runs candidates in isolation, and a _package_name helper in base.py to eliminate repeated module-string parsing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 56de213 commit 93d8da4

8 files changed

Lines changed: 737 additions & 20 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add helpers for generating and validating discovered Autodiscovery configurations.

datadog_checks_base/datadog_checks/base/checks/base.py

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import os
1111
import re
1212
from collections import deque
13+
from collections.abc import Iterable
1314
from os.path import basename
1415
from pathlib import Path
1516
from typing import (
@@ -65,6 +66,7 @@
6566
import unicodedata as _module_unicodedata
6667

6768
from datadog_checks.base.utils.diagnose import Diagnosis
69+
from datadog_checks.base.utils.discovery import Service
6870
from datadog_checks.base.utils.http import RequestsWrapper
6971
from datadog_checks.base.utils.metadata import MetadataManager
7072

@@ -179,6 +181,33 @@ def __init_subclass__(cls, *args, **kwargs):
179181
except Exception:
180182
return cls
181183

184+
@classmethod
185+
def generate_configs(cls, service: Service) -> Iterable[dict[str, Any]]:
186+
"""
187+
Yield candidate full configurations for service discovery.
188+
189+
Integrations can opt into config discovery by declaring a discovery
190+
stanza in their spec and generating config_models.discovery.
191+
"""
192+
from datadog_checks.base.utils.discovery.probe import generated_discovery_candidates
193+
194+
return generated_discovery_candidates(cls, service)
195+
196+
@classmethod
197+
def discover_config(cls, service_json: str) -> str:
198+
"""
199+
Return discovered configurations for an AD service payload.
200+
201+
The Agent calls this classmethod through rtloader. Candidate configs
202+
are generated by ``generate_configs`` and accepted only when the real
203+
check can run against their metric instances successfully. Returns the
204+
first accepted candidate only (first-match-wins); remaining candidates
205+
are not evaluated.
206+
"""
207+
from datadog_checks.base.utils.discovery.probe import run_discovery
208+
209+
return run_discovery(cls, service_json)
210+
182211
def __init__(self, *args, **kwargs):
183212
# type: (*Any, **Any) -> None
184213
"""
@@ -524,9 +553,8 @@ def check_version(self):
524553
Return the dynamically detected integration version.
525554
"""
526555
if not hasattr(self, '_check_version'):
527-
# 'datadog_checks.<PACKAGE>.<MODULE>...'
528-
module_parts = self.__module__.split('.')
529-
package_path = '.'.join(module_parts[:2])
556+
pkg = _package_name(self.__module__)
557+
package_path = f'datadog_checks.{pkg}' if pkg else self.__module__
530558
package = importlib.import_module(package_path)
531559

532560
# Provide a default just in case
@@ -545,8 +573,8 @@ def _get_package_dir(self) -> Path:
545573
used by :attr:`check_version`.
546574
"""
547575
if not hasattr(self, '_package_dir'):
548-
module_parts = self.__module__.split('.')
549-
package_path = '.'.join(module_parts[:2])
576+
pkg = _package_name(self.__module__)
577+
package_path = f'datadog_checks.{pkg}' if pkg else self.__module__
550578
package = importlib.import_module(package_path)
551579
if package.__file__ is not None:
552580
self._package_dir = Path(package.__file__).parent
@@ -613,9 +641,8 @@ def log_typos_in_options(self, user_config, models_config, level):
613641

614642
def load_configuration_models(self, package_path=None):
615643
if package_path is None:
616-
# 'datadog_checks.<PACKAGE>.<MODULE>...'
617-
module_parts = self.__module__.split('.')
618-
package_path = '{}.config_models'.format('.'.join(module_parts[:2]))
644+
pkg = _package_name(self.__module__)
645+
package_path = f'datadog_checks.{pkg}.config_models' if pkg else f'{self.__module__}.config_models'
619646
if self._config_model_shared is None:
620647
shared_config = copy.deepcopy(self.init_config)
621648
context = self._get_config_model_context(shared_config)
@@ -752,7 +779,7 @@ def submit_histogram_bucket(
752779
if hostname is None:
753780
hostname = ''
754781

755-
aggregator.submit_histogram_bucket(
782+
self._agg().submit_histogram_bucket(
756783
self,
757784
self.check_id,
758785
self._format_namespace(name, raw),
@@ -770,28 +797,28 @@ def database_monitoring_query_sample(self, raw_event):
770797
if raw_event is None:
771798
return
772799

773-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-samples")
800+
self._agg().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-samples")
774801

775802
def database_monitoring_query_metrics(self, raw_event):
776803
# type: (str) -> None
777804
if raw_event is None:
778805
return
779806

780-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metrics")
807+
self._agg().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metrics")
781808

782809
def database_monitoring_query_activity(self, raw_event):
783810
# type: (str) -> None
784811
if raw_event is None:
785812
return
786813

787-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-activity")
814+
self._agg().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-activity")
788815

789816
def database_monitoring_metadata(self, raw_event):
790817
# type: (str) -> None
791818
if raw_event is None:
792819
return
793820

794-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metadata")
821+
self._agg().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metadata")
795822

796823
def event_platform_event(self, raw_event, event_track_type):
797824
# type: (str | bytes, str) -> None
@@ -810,7 +837,7 @@ def event_platform_event(self, raw_event, event_track_type):
810837
raw_event = bytes(raw_event)
811838
elif not isinstance(raw_event, bytes):
812839
raw_event = to_native_string(raw_event)
813-
aggregator.submit_event_platform_event(self, self.check_id, raw_event, event_track_type)
840+
self._agg().submit_event_platform_event(self, self.check_id, raw_event, event_track_type)
814841

815842
def submit_generic_resource(self, *, type, key, fields, include, seen_at=None, expire_at=None):
816843
# type: (str, str, dict | None, dict, int | None, int | None) -> None
@@ -974,6 +1001,10 @@ def _metric_excluded(self, metric_name):
9741001

9751002
return self.exclude_metrics_pattern.search(metric_name) is not None
9761003

1004+
def _agg(self) -> Any:
1005+
"""Return the active aggregator: proxy during a discovery probe, module singleton otherwise."""
1006+
return getattr(self, '_discovery_aggregator', None) or aggregator
1007+
9771008
def _submit_metric(
9781009
self, mtype, name, value, tags=None, hostname=None, device_name=None, raw=False, flush_first_value=False
9791010
):
@@ -1012,7 +1043,7 @@ def _submit_metric(
10121043
self.warning(err_msg)
10131044
return
10141045

1015-
aggregator.submit_metric(self, self.check_id, mtype, name, value, tags, hostname, flush_first_value)
1046+
self._agg().submit_metric(self, self.check_id, mtype, name, value, tags, hostname, flush_first_value)
10161047

10171048
def gauge(self, name, value, tags=None, hostname=None, device_name=None, raw=False):
10181049
# type: (str, float, Sequence[str], str, str, bool) -> None
@@ -1233,7 +1264,7 @@ def service_check(self, name, status, tags=None, hostname=None, message=None, ra
12331264

12341265
message = self.sanitize(message)
12351266

1236-
aggregator.submit_service_check(
1267+
self._agg().submit_service_check(
12371268
self, self.check_id, self._format_namespace(name, raw), status, tags, hostname, message
12381269
)
12391270

@@ -1649,7 +1680,7 @@ def event(self, event):
16491680
if self.__NAMESPACE__:
16501681
event.setdefault('source_type_name', self.__NAMESPACE__)
16511682

1652-
aggregator.submit_event(self, self.check_id, event)
1683+
self._agg().submit_event(self, self.check_id, event)
16531684

16541685
def _normalize_tags_type(self, tags, device_name=None, metric_name=None):
16551686
# type: (Sequence[Union[None, str, bytes]], str, str) -> List[str]
@@ -1759,3 +1790,10 @@ def load_config(yaml_str: str) -> Any:
17591790
raise ValueError(f'Failed to load config: {stderr.decode("utf-8", errors="replace")}')
17601791

17611792
return _parse_ast_config(stdout.strip().decode('utf-8'))
1793+
1794+
1795+
def _package_name(module: str) -> str | None:
1796+
parts = module.split('.')
1797+
if len(parts) >= 2 and parts[0] == 'datadog_checks':
1798+
return parts[1]
1799+
return None
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# (C) Datadog, Inc. 2025-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4-
from .discovery import Discovery
4+
from .discovery import Discovery, Port, Service, candidate_ports, from_ports
55

6-
__all__ = ['Discovery']
6+
__all__ = ['Discovery', 'Port', 'Service', 'candidate_ports', 'from_ports']

datadog_checks_base/datadog_checks/base/utils/discovery/discovery.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
# (C) Datadog, Inc. 2023-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from collections.abc import Iterable, Iterator
5+
from dataclasses import dataclass, field
6+
47
from .cache import Cache
58
from .filter import Filter
69

710

11+
@dataclass(frozen=True)
12+
class Port:
13+
"""An Autodiscovery-exposed port on a service."""
14+
15+
number: int
16+
name: str = ""
17+
18+
19+
@dataclass(frozen=True)
20+
class Service:
21+
"""An Autodiscovery-discovered service instance."""
22+
23+
id: str
24+
host: str
25+
ports: tuple[Port, ...] = field(default_factory=tuple)
26+
27+
828
class Discovery:
929
def __init__(
1030
self,
@@ -21,3 +41,25 @@ def __init__(
2141
def get_items(self):
2242
items = self._cache.get_items()
2343
return self._filter.get_items(items)
44+
45+
46+
def candidate_ports(service: Service, hints: Iterable[int]) -> Iterator[Port]:
47+
"""Yield hinted ports first, then remaining service ports."""
48+
by_number = {port.number: port for port in service.ports}
49+
seen: set[int] = set()
50+
51+
for hint in hints:
52+
if hint in by_number and hint not in seen:
53+
seen.add(hint)
54+
yield by_number[hint]
55+
56+
for port in service.ports:
57+
if port.number not in seen:
58+
seen.add(port.number)
59+
yield port
60+
61+
62+
def from_ports(service: Service, *, port_hints: Iterable[int]) -> Iterator[dict[str, Port]]:
63+
"""Yield a ``{'port': Port}`` render context per candidate port."""
64+
for port in candidate_ports(service, port_hints):
65+
yield {'port': port}

0 commit comments

Comments
 (0)