Skip to content
Closed
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
37 changes: 35 additions & 2 deletions ddtrace/internal/remoteconfig/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@
from typing import Optional
from typing import Sequence
import uuid
import weakref

import ddtrace
from ddtrace.internal import forksafe
from ddtrace.internal import gitmetadata
from ddtrace.internal import process_tags
from ddtrace.internal import runtime
from ddtrace.internal.hostname import get_hostname
from ddtrace.internal.logger import get_logger
from ddtrace.internal.packages import is_distribution_available
from ddtrace.internal.remoteconfig import ConfigMetadata
from ddtrace.internal.remoteconfig import Payload
from ddtrace.internal.remoteconfig import PayloadType
from ddtrace.internal.remoteconfig import RCCallback
from ddtrace.internal.runtime import get_runtime_id
from ddtrace.internal.runtime import on_runtime_id_change
from ddtrace.internal.settings._agent import config as agent_config
from ddtrace.internal.settings._core import DDConfig
from ddtrace.internal.telemetry import telemetry_writer
Expand Down Expand Up @@ -98,6 +101,17 @@ def __init__(self) -> None:
self._native: Optional[Any] = None
self._reader: Optional[Any] = None

client_ref = weakref.ref(self)

# Runtime keeps callbacks in a module-level set, so registering a bound
# method would keep this client alive after callers drop their reference.
def _on_identity_refresh(new_runtime_id: str) -> None:
client = client_ref()
if client is not None:
client._on_identity_refresh(new_runtime_id)

on_runtime_id_change(_on_identity_refresh)
Comment on lines +104 to +113

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably just add a remove_on_runtime_id_change(func) method, then this can all go away and we get:

def __init__(self):
    on_runtime_id_change(self._on_identity_refresh)

def __del__(self):
    remove_on_runtime_id_change(self._on_identity_refresh)


def ensure_native(self) -> Any:
if self._native is None:
from ddtrace.internal.native import RemoteConfigClient as _NativeClient
Expand All @@ -109,7 +123,7 @@ def ensure_native(self) -> Any:
agent_url=str(self.agent_url),
tracer_version=tracer_version,
client_id=self.id,
runtime_id=runtime.get_runtime_id(),
runtime_id=get_runtime_id(),
service=ddtrace.config.service or "",
env=ddtrace.config.env or "",
app_version=ddtrace.config.version or "",
Expand All @@ -125,6 +139,25 @@ def ensure_native(self) -> Any:
def renew_id(self) -> None:
self.id = str(uuid.uuid4())

def _on_identity_refresh(self, new_runtime_id: str) -> None:
# Regenerate the client id and drop the native client, which bakes both ids in
# as immutable constructor arguments (get_client_id() is documented "stable for
# the process lifetime"). The next ensure_native() call rebuilds it bound to the
Comment on lines +144 to +145

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this definition of "stable for the process lifetime", the new changes to refresh the runtime id at runtime seem to go against this expectation.

Should MicroVM be using a different identifier for this instead of runtime-id?

Do we need to change all expectations in shared components in libdatadog and all repos to no longer assume runtime-id is process stable? which is mostly the changes we are making here

# fresh ids. Safe across threads: request() captures self._native into a local
# before calling .poll(), so an in-flight poll on the old client is unaffected.
# After fork, keep a reader over inherited SHM before dropping the inherited
# native client; otherwise forked consumers lose the only handles they can read.
if forksafe.is_fork_child():
if self._native is not None and self._reader is None:
try:
self._reader = self._native.make_reader()
except Exception:
log.debug("failed to create remote config reader after fork", exc_info=True)
else:
self._reader = None
self.renew_id()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the AppSec RC client-id cache

When runtime.refresh_identity() runs while AppSec Remote Configuration is enabled, this renews the client ID, but enable_appsec_rc() cached the previous value in asm_config._rc_client_id only once (ddtrace/appsec/_remoteconfiguration.py:83). finalize_asm_env() continues tagging every AppSec span with that stale value (ddtrace/appsec/_asm_request_context.py:403-404), while subsequent RC requests use the new ID, breaking correlation between those spans and the RC client after an identity refresh. Propagate the renewed ID to this cache or make the span tag read the current client ID.

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Refresh the AppSec client ID after identity renewal

Remote Config data cannot match AppSec spans from a restored runtime.

Assertion details
  • Input: Enable AppSec Remote Config. Then refresh the runtime identity and finalize a later AppSec span.
  • Expected: AppSec spans must use the new Remote Config client ID after a runtime identity refresh.
  • Actual: The refresh handler renews RemoteConfigClient.id. AppSec still keeps the old value in asm_config._rc_client_id and adds it to later spans.

Was this helpful? React 馃憤 or 馃憥
馃 Datadog Autotest 路 What is Autotest? 路 @DataDog review to ask questions 路 Any feedback? Reach out in #autotest

self._native = None
Comment thread
litianningdatadog marked this conversation as resolved.
Comment on lines +158 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve broadcasts to already-forked RC consumers

When refresh_identity() runs in an origin process after it has forked workers, dropping this native client also drops the writer for the shared-memory segments inherited by those workers. The replacement client starts with local storage, and existing children cannot inherit its new handles鈥攖he native protocol requires enable_shared_memory() before the fork (src/native/remote_config.rs:9-12)鈥攕o they remain attached to the frozen old mappings and silently stop receiving future Remote Configuration updates, including security configuration. The refresh path needs to preserve or migrate the existing broadcast publisher rather than simply replacing it in an already-forked origin.

AGENTS.md reference: AGENTS.md:L135-L136

Useful? React with 馃憤聽/ 馃憥.

Comment on lines +158 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize native creation with identity refresh

When identity refresh overlaps the first ensure_native() call while _native is still None鈥攆or example, one thread enables RC while a restore hook refreshes identity on another鈥攖he constructor can already have read the old self.id but not yet assigned its result. These lines then renew the ID and clear _native, after which the racing constructor assigns the old-identity native client back into _native; it remains cached, so every later poll continues using the stale client identity despite the completed refresh. Native construction/assignment and identity invalidation need a shared synchronization or generation check.

Useful? React with 馃憤聽/ 馃憥.


def register_callback(self, product_name: "RemoteConfigProduct", callback: RCCallback) -> None:
self._product_callbacks[product_name] = callback
log.debug("[%s][P: %s] Registered callback for product %s", os.getpid(), os.getppid(), product_name)
Expand Down
65 changes: 65 additions & 0 deletions tests/internal/remoteconfig/test_remoteconfig_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,68 @@ def test_enable_builds_native_runtime_before_registering_fork_hook(monkeypatch):
assert poller.enable() is True

assert order == ["native", "before_fork", "start"], order


def test_identity_refresh_renews_client_id_and_drops_native():
# get_client_id() on the native client is documented "stable for the process lifetime",
# so refreshing must drop it (not mutate it in place) for the id to actually change.
client = RemoteConfigClient()
old_id = client.id
client.ensure_native()
assert client._native is not None

client._on_identity_refresh("some-new-runtime-id")

assert client.id != old_id
assert client._native is None


def test_identity_refresh_drops_cached_reader_outside_fork():
client = RemoteConfigClient()
client._reader = object()

client._on_identity_refresh("some-new-runtime-id")

assert client._reader is None


def test_identity_refresh_callback_does_not_keep_client_alive():
import gc
import weakref

client = RemoteConfigClient()
client_ref = weakref.ref(client)

del client
gc.collect()

assert client_ref() is None


def test_identity_refresh_rebuilds_native_client_with_fresh_id():
client = RemoteConfigClient()
native_before = client.ensure_native()
old_native_client_id = native_before.get_client_id()

client._on_identity_refresh("some-new-runtime-id")
native_after = client.ensure_native()

assert native_after is not native_before
assert native_after.get_client_id() == client.id
assert native_after.get_client_id() != old_native_client_id


@pytest.mark.subprocess
def test_identity_refresh_wired_to_runtime_id_change():
"""A RemoteConfigClient subscribes itself at construction; refresh_identity() reaches it."""
from ddtrace.internal import runtime
from ddtrace.internal.remoteconfig.client import RemoteConfigClient

client = RemoteConfigClient()
old_id = client.id
client.ensure_native()

runtime.refresh_identity()

assert client.id != old_id
assert client._native is None
Loading