Skip to content

Commit acc71f6

Browse files
committed
Introduce the global DD_AGENTLESS_ENABLED flag
1 parent 4a192f8 commit acc71f6

22 files changed

Lines changed: 602 additions & 152 deletions

.gitlab/package.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@ variables:
284284
WINDOWS_ARCH: ["amd64", "x86"]
285285
variables:
286286
WINDOWS_BUILD_IMAGE: "registry.ddbuild.io/images/mirror/dd-trace-py/windows-build:d4d18a67d43d400d3ecc6bda777a5e233a24f434@sha256:58c3e28646ddf8a1021a079a380c8840e51f0b88c1489a522beee6ec99413019"
287+
before_script:
288+
- git config --global core.longpaths true
287289
script:
288290
- bash .gitlab/scripts/build-wheel-windows.sh
289291
artifacts:

ddtrace/internal/native/_native.pyi

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,6 +1363,9 @@ class RemoteConfigClient:
13631363
"""Native single-target remote config client (origin process).
13641364
13651365
Children consume published configs via :class:`RemoteConfigReader` instead.
1366+
1367+
Passing ``api_key`` (together with ``site`` and ``hostname``) selects agentless
1368+
mode: configs are fetched from ``config.<site>`` rather than from the agent.
13661369
"""
13671370

13681371
def __new__(
@@ -1381,6 +1384,9 @@ class RemoteConfigClient:
13811384
process_tags: Optional[list[tuple[str, str]]] = None,
13821385
timeout_ms: int = 5000,
13831386
test_session_token: Optional[str] = None,
1387+
site: Optional[str] = None,
1388+
api_key: Optional[str] = None,
1389+
hostname: Optional[str] = None,
13841390
) -> "RemoteConfigClient": ...
13851391
def add_capabilities(self, capabilities: list[RemoteConfigCapabilities]) -> None:
13861392
"""Add capabilities the client advertises to the agent."""
@@ -1401,6 +1407,13 @@ class RemoteConfigClient:
14011407
def get_client_id(self) -> str:
14021408
"""The remote config client id (a UUID); stable for the process lifetime."""
14031409
...
1410+
def get_refresh_interval(self) -> float:
1411+
"""Seconds to wait before the next poll.
1412+
1413+
Agentless mode follows the interval the backend recommends, refreshed on
1414+
every successful fetch; against the agent this is a fixed default.
1415+
"""
1416+
...
14041417
def enable_shared_memory(self) -> None:
14051418
"""Enable cross-process broadcast. Call on the origin before forking."""
14061419
...

ddtrace/internal/remoteconfig/client.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class RemoteConfigClient:
8585

8686
def __init__(self) -> None:
8787
self.id = str(uuid.uuid4())
88+
self.agentless = ddtrace.config._agentless_enabled
8889
self.agent_url = agent_config.trace_agent_url
8990

9091
# Product callbacks for single subscriber architecture
@@ -104,6 +105,17 @@ def ensure_native(self) -> Any:
104105
from ddtrace.internal.native_runtime import get_native_runtime
105106

106107
tracer_version = _pep440_to_semver()
108+
# In agentless mode the API key selects the direct-to-backend fetcher;
109+
# site and hostname identify the intake and this client to it.
110+
agentless_kwargs = (
111+
{
112+
"site": ddtrace.config._dd_site,
113+
"api_key": ddtrace.config._dd_api_key,
114+
"hostname": get_hostname(),
115+
}
116+
if self.agentless
117+
else {}
118+
)
107119
self._native = _NativeClient(
108120
get_native_runtime(),
109121
agent_url=str(self.agent_url),
@@ -117,6 +129,7 @@ def ensure_native(self) -> Any:
117129
process_tags=_build_process_tags(),
118130
timeout_ms=int(agent_config.trace_agent_timeout_seconds * 1000),
119131
test_session_token=get_test_session_token(),
132+
**agentless_kwargs,
120133
)
121134
if self._capability_values:
122135
self._native.add_capabilities(self._capability_values)
@@ -125,6 +138,16 @@ def ensure_native(self) -> Any:
125138
def renew_id(self) -> None:
126139
self.id = str(uuid.uuid4())
127140

141+
def refresh_interval(self) -> Optional[float]:
142+
"""Seconds the backend wants us to wait before polling again.
143+
144+
Only agentless fetches carry a server-recommended interval; None means
145+
"keep whatever interval the poller was configured with".
146+
"""
147+
if self._native is None or not self.agentless:
148+
return None
149+
return self._native.get_refresh_interval()
150+
128151
def register_callback(self, product_name: "RemoteConfigProduct", callback: RCCallback) -> None:
129152
self._product_callbacks[product_name] = callback
130153
log.debug("[%s][P: %s] Registered callback for product %s", os.getpid(), os.getppid(), product_name)

ddtrace/internal/remoteconfig/worker.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def __init__(self) -> None:
4040
interval=ddconfig._remote_config_poll_interval, no_wait_at_start=True, autorestart=False
4141
)
4242
self._client = RemoteConfigClient()
43-
self._state = self._agent_check
43+
# Agentless fetches go straight to the Remote Config backend, so there is
44+
# no agent to negotiate the v0.7/config endpoint with.
45+
self._state = self._online if self._client.agentless else self._agent_check
4446
self._parent_id = os.getpid()
4547
self._capabilities_map: "dict[RemoteConfigCapabilities, RemoteConfigProduct]" = dict()
4648
self._consecutive_failures = 0
@@ -74,21 +76,31 @@ def _agent_check(self) -> None:
7476

7577
def _online(self) -> None:
7678
with StopWatch() as sw:
77-
if not self._client.request():
78-
self._consecutive_failures += 1
79-
if self._consecutive_failures >= self._MAX_CONSECUTIVE_FAILURES:
80-
self._state = self._agent_check
81-
self._consecutive_failures = 0
82-
return
79+
succeeded = self._client.request()
80+
81+
# The interval is the backend's to decide in agentless mode: it recommends
82+
# a cadence on every successful fetch and a backoff after a failed one.
83+
interval = self._client.refresh_interval()
84+
if interval is not None and interval != self.interval:
85+
log.debug("Remote Config poll interval set to %.3fs by the backend", interval)
86+
self.interval = interval
87+
88+
if not succeeded:
89+
self._consecutive_failures += 1
90+
# Without an agent there is nothing to fall back to, so keep retrying
91+
# on the backoff the native client asked for.
92+
if self._consecutive_failures >= self._MAX_CONSECUTIVE_FAILURES and not self._client.agentless:
93+
self._state = self._agent_check
94+
self._consecutive_failures = 0
95+
return
8396

8497
self._consecutive_failures = 0
85-
elapsed = sw.elapsed()
8698
log.debug(
8799
"[%d][P: %d] Datadog Remote Config Poller sent request to %s in %.5fs",
88100
os.getpid(),
89101
os.getppid(),
90-
self._client.agent_url,
91-
elapsed,
102+
"the Remote Config intake" if self._client.agentless else self._client.agent_url,
103+
sw.elapsed(),
92104
)
93105

94106
def periodic(self) -> None:
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import typing as t
2+
3+
from ddtrace.internal.settings._core import DDConfig
4+
5+
6+
class AgentlessConfig(DDConfig):
7+
# No __prefix__: _DD_APM_TRACING_AGENTLESS_ENABLED start with _.
8+
9+
api_key = DDConfig.v(t.Optional[str], "dd.api_key", default=None)
10+
site = DDConfig.v(str, "dd.site", default="datadoghq.com")
11+
12+
#: The global switch. Every product setting below defaults to it.
13+
enabled = DDConfig.v(bool, "dd.agentless.enabled", default=False)
14+
15+
# Raw per-product overrides; None means "follow the global switch". Read the
16+
# resolved values below instead of these.
17+
_apm_tracing = DDConfig.v(t.Optional[bool], "_dd.apm.tracing.agentless.enabled", default=None)
18+
_ci_visibility = DDConfig.v(t.Optional[bool], "dd.civisibility.agentless.enabled", default=None)
19+
_llmobs = DDConfig.v(t.Optional[bool], "dd.llmobs.agentless.enabled", default=None)
20+
21+
apm_tracing = DDConfig.d(bool, lambda c: c.enabled if c._apm_tracing is None else c._apm_tracing)
22+
ci_visibility = DDConfig.d(bool, lambda c: c.enabled if c._ci_visibility is None else c._ci_visibility)
23+
# LLM Observability keeps a third state: left unset (and with no global switch) it
24+
# probes the agent at startup and decides then, so it must not collapse to False.
25+
llmobs = DDConfig.d(t.Optional[bool], lambda c: True if c.enabled and c._llmobs is None else c._llmobs)
26+
27+
#: Whether anything at all submits agentlessly. Products without a transport
28+
#: setting of their own (instrumentation telemetry) follow this.
29+
any_enabled = DDConfig.d(bool, lambda c: bool(c.enabled or c.apm_tracing or c.ci_visibility or c.llmobs))
30+
31+
def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
32+
super().__init__(*args, **kwargs)
33+
34+
if self.enabled and not self.api_key:
35+
msg = (
36+
"DD_AGENTLESS_ENABLED is set but DD_API_KEY is not. Agentless mode submits data "
37+
"straight to the Datadog intake, which is not possible without an API key. "
38+
"Set DD_API_KEY, or unset DD_AGENTLESS_ENABLED to submit through the agent."
39+
)
40+
raise ValueError(msg)
41+
42+
def reported_configuration(self) -> "list[tuple[str, t.Any, str]]":
43+
"""The (environment variable, effective value, origin) triples to report as telemetry.
44+
45+
Agentless config is resolved early, and we thus must explicitly report our config.
46+
"""
47+
return [
48+
(env_name, value, self.value_source(env_name))
49+
for env_name, value in (
50+
("DD_AGENTLESS_ENABLED", self.enabled),
51+
("DD_SITE", self.site),
52+
("_DD_APM_TRACING_AGENTLESS_ENABLED", self.apm_tracing),
53+
("DD_CIVISIBILITY_AGENTLESS_ENABLED", self.ci_visibility),
54+
("DD_LLMOBS_AGENTLESS_ENABLED", self.llmobs),
55+
)
56+
]
57+
58+
59+
config = AgentlessConfig()

ddtrace/internal/settings/_config.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ddtrace.internal.serverless import in_azure_function
3333
from ddtrace.internal.serverless import in_gcp_function
3434
from ddtrace.internal.settings import env
35+
from ddtrace.internal.settings._agentless import config as agentless_config
3536
from ddtrace.internal.telemetry import get_config as _get_config
3637
from ddtrace.internal.telemetry import telemetry_writer
3738
from ddtrace.internal.telemetry import validate_and_report_otel_metrics_exporter_enabled
@@ -468,6 +469,14 @@ def __init__(self) -> None:
468469
self._debug_mode = _get_config("DD_TRACE_DEBUG", False, asbool, "OTEL_LOG_LEVEL")
469470
self._startup_logs_enabled = _get_config("DD_TRACE_STARTUP_LOGS", False, asbool)
470471

472+
self._dd_api_key = agentless_config.api_key
473+
self._dd_site = agentless_config.site
474+
self._agentless_enabled = agentless_config.enabled
475+
for _name, _value, _origin in agentless_config.reported_configuration():
476+
telemetry_writer.add_configuration(_name, _value, _origin)
477+
478+
self._dd_app_key = _get_config("DD_APP_KEY", report_telemetry=False)
479+
471480
self._trace_rate_limit: int = _get_config("DD_TRACE_RATE_LIMIT", DEFAULT_SAMPLING_RATE_LIMIT, int)
472481
if self._trace_rate_limit != DEFAULT_SAMPLING_RATE_LIMIT and self._trace_sampling_rules in ("", "[]"):
473482
log.warning(
@@ -678,7 +687,7 @@ def __init__(self) -> None:
678687
log.warning("Invalid obfuscation pattern, disabling query string tracing", exc_info=True)
679688
self._http_tag_query_string = False # Disable query string tagging if malformed obfuscation pattern
680689

681-
self._ci_visibility_agentless_enabled = _get_config("DD_CIVISIBILITY_AGENTLESS_ENABLED", False, asbool)
690+
self._ci_visibility_agentless_enabled = agentless_config.ci_visibility
682691
self._ci_visibility_agentless_url = _get_config("DD_CIVISIBILITY_AGENTLESS_URL", "")
683692
self._ci_visibility_intelligent_testrunner_enabled = _get_config("DD_CIVISIBILITY_ITR_ENABLED", True, asbool)
684693
self._ci_visibility_log_level = _get_config("DD_CIVISIBILITY_LOG_LEVEL", "info")
@@ -701,11 +710,7 @@ def __init__(self) -> None:
701710

702711
self._trace_methods = _get_config("DD_TRACE_METHODS")
703712

704-
self._dd_api_key = _get_config("DD_API_KEY", report_telemetry=False)
705-
self._dd_app_key = _get_config("DD_APP_KEY", report_telemetry=False)
706-
self._dd_site = _get_config("DD_SITE", "datadoghq.com")
707-
708-
self._llmobs_agentless_enabled = _get_config("DD_LLMOBS_AGENTLESS_ENABLED", None, asbool)
713+
self._llmobs_agentless_enabled = agentless_config.llmobs
709714
self._llmobs_instrumented_proxy_urls = _get_config(
710715
"DD_LLMOBS_INSTRUMENTED_PROXY_URLS", None, lambda x: set(x.strip().split(","))
711716
)
@@ -761,7 +766,7 @@ def __init__(self) -> None:
761766
"DD_TRACE_EXPERIMENTAL_LONG_RUNNING_INITIAL_FLUSH_INTERVAL", default=10.0, modifier=float
762767
)
763768
# When True, traces are sent via the JSON span intake (agentless EvP), e.g. browser-intake-*.
764-
self._trace_agentless_enabled = _get_config("_DD_APM_TRACING_AGENTLESS_ENABLED", False, asbool)
769+
self._trace_agentless_enabled = agentless_config.apm_tracing
765770
if self._trace_agentless_enabled:
766771
log.debug(
767772
"APM Agentless enabled: health metrics and client-side stats are disabled. "

ddtrace/internal/settings/_supported_configurations.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"DATADOG_TAGS",
1010
"DATADOG_TRACE_AGENT_HOSTNAME",
1111
"DD_ACTION_EXECUTION_ID",
12+
"DD_AGENTLESS_ENABLED",
1213
"DD_AGENTLESS_LOG_SUBMISSION_ENABLED",
1314
"DD_AGENT_HOST",
1415
"DD_AGENT_PORT",

ddtrace/internal/settings/_telemetry.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,9 @@
88
class TelemetryConfig(DDConfig):
99
__prefix__ = "dd"
1010

11-
API_KEY = DDConfig.v(t.Optional[str], "api_key", default=None)
12-
SITE = DDConfig.v(str, "site", default="datadoghq.com")
1311
ENV = DDConfig.v(str, "env", default="")
1412
SERVICE = DDConfig.v(str, "service", default=detect_service(sys.argv) or "unnamed-python-service")
1513
VERSION = DDConfig.v(str, "version", default="")
16-
AGENTLESS_MODE = DDConfig.v(bool, "civisibility.agentless.enabled", default=False)
1714
DEBUG = DDConfig.v(bool, "internal.telemetry.debug.enabled", default=False)
1815
HEARTBEAT_INTERVAL = DDConfig.v(float, "telemetry.heartbeat_interval", default=60.0)
1916
TELEMETRY_ENABLED = DDConfig.v(bool, "instrumentation_telemetry.enabled", default=True)

ddtrace/internal/settings/dynamic_instrumentation.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,6 @@ def _derive_tags(c: DDConfig) -> str:
3030
return ",".join([":".join((k, v)) for (k, v) in _tags.items() if v is not None])
3131

3232

33-
def _resolve_agentless(c: DDConfig) -> bool:
34-
"""Whether the APM trace writer should run in agentless mode.
35-
36-
Falls back when agentless is requested but ``DD_API_KEY`` is unset.
37-
"""
38-
if not ddconfig._trace_agentless_enabled:
39-
return False
40-
if not ddconfig._dd_api_key:
41-
return False
42-
return True
43-
44-
4533
def normalize_ident(ident: str) -> str:
4634
return ident.strip().lower().replace("_", "")
4735

@@ -77,7 +65,9 @@ class DynamicInstrumentationConfig(DDConfig):
7765
help="Enable Dynamic Instrumentation",
7866
)
7967

80-
_agentless = DDConfig.d(bool, _resolve_agentless)
68+
# A callable, not a value: this has to be read when the config is resolved, not when the
69+
# class is defined.
70+
_agentless = DDConfig.d(bool, lambda _: bool(ddconfig._agentless_enabled))
8171

8272
metrics = DDConfig.v(
8373
bool,

ddtrace/internal/telemetry/writer.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from ddtrace.internal.logger import get_logger
1515
from ddtrace.internal.packages import is_user_code
1616
from ddtrace.internal.settings._agent import config as agent_config
17+
from ddtrace.internal.settings._agentless import config as agentless_config
1718
from ddtrace.internal.settings._telemetry import config
1819

1920
from ...internal import atexit
@@ -165,9 +166,11 @@ def __init__(self, agentless: Optional[bool] = None) -> None:
165166
self._enabled = config.TELEMETRY_ENABLED
166167

167168
if agentless is None:
168-
agentless = config.AGENTLESS_MODE or config.API_KEY not in (None, "")
169+
# An API key on its own says nothing about how data should be submitted;
170+
# only an explicit agentless setting does.
171+
agentless = agentless_config.any_enabled
169172

170-
if agentless and not config.API_KEY:
173+
if agentless and not agentless_config.api_key:
171174
log.debug("Disabling telemetry: no Datadog API key found in agentless mode")
172175
self._enabled = False
173176

@@ -239,8 +242,8 @@ def _build_worker(self) -> "TelemetryWorker":
239242
endpoint_url = "file://" + os.path.join(self._payload_file_dir, "")
240243
api_key = None
241244
elif self._agentless:
242-
endpoint_url = _agentless_endpoint_url(config.SITE)
243-
api_key = config.API_KEY
245+
endpoint_url = _agentless_endpoint_url(agentless_config.site)
246+
api_key = agentless_config.api_key
244247
else:
245248
endpoint_url = agent_config.trace_agent_url
246249
api_key = None
@@ -412,7 +415,7 @@ def enable_agentless_client(self, enabled: bool = True) -> None:
412415

413416
self._agentless = enabled
414417

415-
if enabled and not config.API_KEY:
418+
if enabled and not agentless_config.api_key:
416419
log.debug("Cannot switch telemetry to agentless mode: no Datadog API key found")
417420
return
418421

0 commit comments

Comments
 (0)