Skip to content

Commit 1e709af

Browse files
vitkyrkaclaude
andauthored
Add configuration discovery infrastructure to datadog_checks_base (#23963)
* 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> * walrus * comment * Use pydantic BaseModel for Port and Service Simplifies _parse_service_json: the Agent always sends well-typed JSON (int number, string name), so manual field extraction and to_native_string coercion are unnecessary — model_validate handles all of it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * validate * Remove from_ports from base; add discovery_strategy decorator Move from_ports strategy logic to the dev-side codegen registry (Phase 2). Base retains only the runtime primitives: Service, Port, candidate_ports, and the probing harness. Add the discovery_strategy decorator for custom (local:) strategies that run arbitrary integration code at Agent runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * datadog_checks_base: match changelog text to phase 1 spec Use the wording prescribed by the phase 1 plan: the Phase 1 changelog describes the discovery runtime (Service/Port types, candidate_ports, probing harness, discovery entry points), not the codegen/validation tooling, which belongs to Phase 2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix test mock to patch import_module where it is called The test patched datadog_checks.base.checks.base.importlib.import_module, but generate_configs() delegates to the probe module, which calls probe.importlib.import_module. Patch that path instead so the mock is actually exercised. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Remove __discovery_provides__ attribute and dead-code tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cb40f3f commit 1e709af

9 files changed

Lines changed: 737 additions & 12 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add configuration discovery runtime (Service/Port types, candidate_ports, probing harness, and discovery entry points).

datadog_checks_base/datadog_checks/base/checks/base.py

Lines changed: 42 additions & 9 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
"""
@@ -752,7 +781,7 @@ def submit_histogram_bucket(
752781
if hostname is None:
753782
hostname = ''
754783

755-
aggregator.submit_histogram_bucket(
784+
self._aggregator().submit_histogram_bucket(
756785
self,
757786
self.check_id,
758787
self._format_namespace(name, raw),
@@ -770,28 +799,28 @@ def database_monitoring_query_sample(self, raw_event):
770799
if raw_event is None:
771800
return
772801

773-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-samples")
802+
self._aggregator().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-samples")
774803

775804
def database_monitoring_query_metrics(self, raw_event):
776805
# type: (str) -> None
777806
if raw_event is None:
778807
return
779808

780-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metrics")
809+
self._aggregator().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metrics")
781810

782811
def database_monitoring_query_activity(self, raw_event):
783812
# type: (str) -> None
784813
if raw_event is None:
785814
return
786815

787-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-activity")
816+
self._aggregator().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-activity")
788817

789818
def database_monitoring_metadata(self, raw_event):
790819
# type: (str) -> None
791820
if raw_event is None:
792821
return
793822

794-
aggregator.submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metadata")
823+
self._aggregator().submit_event_platform_event(self, self.check_id, to_native_string(raw_event), "dbm-metadata")
795824

796825
def event_platform_event(self, raw_event, event_track_type):
797826
# type: (str | bytes, str) -> None
@@ -810,7 +839,7 @@ def event_platform_event(self, raw_event, event_track_type):
810839
raw_event = bytes(raw_event)
811840
elif not isinstance(raw_event, bytes):
812841
raw_event = to_native_string(raw_event)
813-
aggregator.submit_event_platform_event(self, self.check_id, raw_event, event_track_type)
842+
self._aggregator().submit_event_platform_event(self, self.check_id, raw_event, event_track_type)
814843

815844
def submit_generic_resource(self, *, type, key, fields, include, seen_at=None, expire_at=None):
816845
# type: (str, str, dict | None, dict, int | None, int | None) -> None
@@ -974,6 +1003,10 @@ def _metric_excluded(self, metric_name):
9741003

9751004
return self.exclude_metrics_pattern.search(metric_name) is not None
9761005

1006+
def _aggregator(self) -> Any:
1007+
"""Return the active aggregator: proxy during a discovery probe, module singleton otherwise."""
1008+
return getattr(self, '_discovery_aggregator', None) or aggregator
1009+
9771010
def _submit_metric(
9781011
self, mtype, name, value, tags=None, hostname=None, device_name=None, raw=False, flush_first_value=False
9791012
):
@@ -1012,7 +1045,7 @@ def _submit_metric(
10121045
self.warning(err_msg)
10131046
return
10141047

1015-
aggregator.submit_metric(self, self.check_id, mtype, name, value, tags, hostname, flush_first_value)
1048+
self._aggregator().submit_metric(self, self.check_id, mtype, name, value, tags, hostname, flush_first_value)
10161049

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

12341267
message = self.sanitize(message)
12351268

1236-
aggregator.submit_service_check(
1269+
self._aggregator().submit_service_check(
12371270
self, self.check_id, self._format_namespace(name, raw), status, tags, hostname, message
12381271
)
12391272

@@ -1649,7 +1682,7 @@ def event(self, event):
16491682
if self.__NAMESPACE__:
16501683
event.setdefault('source_type_name', self.__NAMESPACE__)
16511684

1652-
aggregator.submit_event(self, self.check_id, event)
1685+
self._aggregator().submit_event(self, self.check_id, event)
16531686

16541687
def _normalize_tags_type(self, tags, device_name=None, metric_name=None):
16551688
# type: (Sequence[Union[None, str, bytes]], str, str) -> List[str]
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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
5+
from .strategies import discovery_strategy
56

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

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
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+
6+
from pydantic import BaseModel, ConfigDict
7+
48
from .cache import Cache
59
from .filter import Filter
610

711

12+
class Port(BaseModel):
13+
"""An Autodiscovery-exposed port on a service."""
14+
15+
model_config = ConfigDict(frozen=True)
16+
17+
number: int
18+
name: str = ""
19+
20+
21+
class Service(BaseModel):
22+
"""An Autodiscovery-discovered service instance."""
23+
24+
model_config = ConfigDict(frozen=True)
25+
26+
id: str
27+
host: str
28+
ports: tuple[Port, ...] = ()
29+
30+
831
class Discovery:
932
def __init__(
1033
self,
@@ -21,3 +44,19 @@ def __init__(
2144
def get_items(self):
2245
items = self._cache.get_items()
2346
return self._filter.get_items(items)
47+
48+
49+
def candidate_ports(service: Service, hints: Iterable[int]) -> Iterator[Port]:
50+
"""Yield hinted ports first, then remaining service ports."""
51+
by_number = {port.number: port for port in service.ports}
52+
seen: set[int] = set()
53+
54+
for hint in hints:
55+
if hint in by_number and hint not in seen:
56+
seen.add(hint)
57+
yield by_number[hint]
58+
59+
for port in service.ports:
60+
if port.number not in seen:
61+
seen.add(port.number)
62+
yield port

0 commit comments

Comments
 (0)