From 2c767b2bd9defc4d24f165c161c2da388b903b29 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:22:41 -0400 Subject: [PATCH 1/5] feat(rum): add rum_permanent_retention_filters resource Permanent RUM retention filters are system-defined with fixed ids (rum_apm_flat_sampling, synthetics_sessions, forced_replay_sessions) identical across orgs, so the filter id needs no remapping -- only the parent application id does. The endpoint set is PATCH-only (no POST/DELETE), so create_resource delegates to update_resource and delete_resource is a no-op, mirroring logs_archances_order. Parent-scoped like rum_retention_filters: synthetic _application_id injected during enumeration, remapped via resource_connections, kept out of excluded_attributes (must survive prep_resource) and excluded from diffs via deep_diff_config.exclude_regex_paths. Only attributes.cross_product_sampling is updatable, so attributes.name/description/editability are excluded. - datadog_sync/model/rum_permanent_retention_filters.py (new) - datadog_sync/models/__init__.py -- register - tests/unit/test_rum_permanent_retention_filters.py (new) -- 6 unit tests - README.md -- add rum_permanent_retention_filters (depends on rum_applications) Integration tests + VCR cassettes deferred (require sandbox-org API access). --- README.md | 2 + .../model/rum_permanent_retention_filters.py | 116 +++++++++++++ datadog_sync/models/__init__.py | 1 + .../test_rum_permanent_retention_filters.py | 154 ++++++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 datadog_sync/model/rum_permanent_retention_filters.py create mode 100644 tests/unit/test_rum_permanent_retention_filters.py diff --git a/README.md b/README.md index 290398c4..95293004 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ When running againts multiple destination organizations, a seperate working dire | roles | Sync Datadog roles. | | rum_applications | Sync Datadog RUM applications. | | rum_metrics | Sync Datadog RUM-based metrics. | +| rum_permanent_retention_filters | Sync Datadog permanent RUM retention filters (configure-only). | | rum_retention_filters | Sync Datadog RUM retention filters (generic + exclusion). | | rum_retention_filters_order | Sync Datadog RUM retention filters order. | | sensitive_data_scanner_groups | Sync SDS groups | @@ -365,6 +366,7 @@ See [Supported resources](#supported-resources) section below for potential reso | roles | - | | rum_applications | - | | rum_metrics | - | +| rum_permanent_retention_filters | rum_applications | | rum_retention_filters | rum_applications | | rum_retention_filters_order | rum_applications, rum_retention_filters | | sensitive_data_scanner_groups | - | diff --git a/datadog_sync/model/rum_permanent_retention_filters.py b/datadog_sync/model/rum_permanent_retention_filters.py new file mode 100644 index 00000000..9a185ce1 --- /dev/null +++ b/datadog_sync/model/rum_permanent_retention_filters.py @@ -0,0 +1,116 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the 3-clause BSD style license (see LICENSE). +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019 Datadog, Inc. + +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, List, Dict, Tuple + +from datadog_sync.utils.base_resource import BaseResource, ResourceConfig + +if TYPE_CHECKING: + from datadog_sync.utils.custom_client import CustomClient + + +class RUMPermanentRetentionFilters(BaseResource): + """Permanent RUM retention filters (configure-only). + + Permanent retention filters are system-defined with fixed ids + (``rum_apm_flat_sampling``, ``synthetics_sessions``, ``forced_replay_sessions``) + identical across orgs, so the filter id needs no remapping — only the parent + application id does. The endpoint set is PATCH-only (no POST/DELETE), so + ``create_resource`` delegates to ``update_resource`` and ``delete_resource`` + is a no-op, mirroring ``logs_archives_order``. + + Like ``rum_retention_filters``, the application id is not part of the filter + body, so a synthetic ``_application_id`` is injected during enumeration and + remapped via ``resource_connections``. It is kept out of ``excluded_attributes`` + (must survive ``prep_resource``) and excluded from diffs via + ``deep_diff_config.exclude_regex_paths``. + """ + + resource_type = "rum_permanent_retention_filters" + resource_config = ResourceConfig( + base_path="/api/v2/rum/applications", + excluded_attributes=[ + "attributes.name", + "attributes.description", + "attributes.editability", + ], + resource_connections={ + "rum_applications": ["_application_id"], + }, + deep_diff_config={ + "ignore_order": True, + "exclude_regex_paths": [r".*\['_application_id'\]"], + }, + skip_resource_mapping=True, + ) + # Additional RUMPermanentRetentionFilters specific attributes + _applications_path = "/api/v2/rum/applications" + + async def get_resources(self, client: CustomClient) -> List[Dict]: + apps = (await client.get(self._applications_path))["data"] + resources: List[Dict] = [] + for app in apps: + app_id = app["id"] + resp = await client.get(f"{self._applications_path}/{app_id}/retention_filters/permanent") + for f in resp["data"]: + f["_application_id"] = app_id + resources.append(f) + return resources + + async def import_resource(self, _id: Optional[str] = None, resource: Optional[Dict] = None) -> Tuple[str, Dict]: + if _id: + # The {permanent_rf_id} GET is parent-scoped; search apps for it. + # Only used by --id-file (not allowlisted for this type). + source_client = self.config.source_client + apps = (await source_client.get(self._applications_path))["data"] + resource = None + for app in apps: + app_id = app["id"] + resp = await source_client.get(f"{self._applications_path}/{app_id}/retention_filters/permanent") + for f in resp["data"]: + if f["id"] == _id: + f["_application_id"] = app_id + resource = f + break + if resource: + break + if resource is None: + raise Exception(f"rum_permanent_retention_filter {_id} not found in any application") + + return resource["id"], resource + + async def pre_resource_action_hook(self, _id, resource: Dict) -> None: + pass + + async def pre_apply_hook(self) -> None: + pass + + async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + # No POST endpoint — permanent filters are system-provisioned with fixed + # ids. Delegate to update (configure the cross_product_sampling). + return await self.update_resource(_id, resource) + + async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + destination_client = self.config.destination_client + app_id = resource.pop("_application_id", None) + destination_state = self.config.state.destination[self.resource_type][_id] + # permanent filter ids are fixed across orgs; the destination id == _id + destination_id = destination_state.get("id", _id) + dest_app_id = destination_state.get("_application_id", app_id) + resource["id"] = destination_id + payload = {"data": resource} + resp = await destination_client.patch( + f"{self._applications_path}/{dest_app_id}/retention_filters/permanent/{destination_id}", + payload, + ) + data = resp["data"] + data["_application_id"] = dest_app_id + return _id, data + + async def delete_resource(self, _id: str) -> None: + self.config.logger.warning( + "rum_permanent_retention_filters cannot be deleted. Removing resource from state only." + ) diff --git a/datadog_sync/models/__init__.py b/datadog_sync/models/__init__.py index a240b536..2b462628 100644 --- a/datadog_sync/models/__init__.py +++ b/datadog_sync/models/__init__.py @@ -29,6 +29,7 @@ from datadog_sync.model.roles import Roles from datadog_sync.model.rum_applications import RUMApplications from datadog_sync.model.rum_metrics import RUMMetrics +from datadog_sync.model.rum_permanent_retention_filters import RUMPermanentRetentionFilters from datadog_sync.model.rum_retention_filters import RUMRetentionFilters from datadog_sync.model.rum_retention_filters_order import RUMRetentionFiltersOrder from datadog_sync.model.security_monitoring_rules import SecurityMonitoringRules diff --git a/tests/unit/test_rum_permanent_retention_filters.py b/tests/unit/test_rum_permanent_retention_filters.py new file mode 100644 index 00000000..6a6ec213 --- /dev/null +++ b/tests/unit/test_rum_permanent_retention_filters.py @@ -0,0 +1,154 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the 3-clause BSD style license (see LICENSE). +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019 Datadog, Inc. + +""" +Unit tests for the RUMPermanentRetentionFilters resource model. + +Permanent RUM retention filters are system-defined with fixed ids +(``rum_apm_flat_sampling``, ``synthetics_sessions``, ``forced_replay_sessions``) +that are identical across orgs, so the filter id needs no remapping — only the +parent application id does. The endpoint set is PATCH-only (no POST/DELETE), so +``create_resource`` delegates to ``update_resource`` and ``delete_resource`` is +a no-op, mirroring ``logs_archives_order``. +""" + +import asyncio +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock + +from datadog_sync.model.rum_permanent_retention_filters import RUMPermanentRetentionFilters + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +_APPS = {"data": [{"id": "app-src"}]} +_PERM = { + "data": [ + { + "id": "synthetics_sessions", + "type": "permanent_retention_filters", + "attributes": { + "name": "Synthetics Sessions", + "description": "system", + "editability": "editable", + "cross_product_sampling": {"enabled": False, "sample_rate": 1.0}, + }, + } + ] +} + + +def test_get_resources_iterates_apps_and_injects_application_id(): + rum = RUMPermanentRetentionFilters(MagicMock()) + client = AsyncMock() + client.get = AsyncMock(side_effect=[_APPS, _PERM]) + + resources = _run(rum.get_resources(client)) + + assert len(resources) == 1 + assert resources[0]["id"] == "synthetics_sessions" + assert resources[0]["_application_id"] == "app-src" + assert client.get.await_count == 2 + + +def test_import_resource_passthrough(): + rum = RUMPermanentRetentionFilters(MagicMock()) + rum.config.source_client = AsyncMock() + resource = _PERM["data"][0] | {"_application_id": "app-src"} + _id, data = _run(rum.import_resource(resource=resource)) + assert _id == "synthetics_sessions" + assert data is resource + + +def test_create_resource_delegates_to_update(): + rum = RUMPermanentRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock( + return_value={"data": {"id": "synthetics_sessions", "type": "permanent_retention_filters", "attributes": {}}} + ) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_permanent_retention_filters"]["synthetics_sessions"] = { + "id": "synthetics_sessions", + "_application_id": "app-dst", + } + + resource = { + "id": "synthetics_sessions", + "type": "permanent_retention_filters", + "attributes": {"cross_product_sampling": {"enabled": True, "sample_rate": 0.5}}, + "_application_id": "app-dst", + } + _id, data = _run(rum.create_resource("synthetics_sessions", resource)) + + # create delegates to update (no POST endpoint) + dest.patch.assert_awaited_once() + assert ( + dest.patch.await_args.args[0] + == "/api/v2/rum/applications/app-dst/retention_filters/permanent/synthetics_sessions" + ) + + +def test_update_resource_patches_permanent_subpath(): + rum = RUMPermanentRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock( + return_value={"data": {"id": "synthetics_sessions", "type": "permanent_retention_filters", "attributes": {}}} + ) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_permanent_retention_filters"]["synthetics_sessions"] = { + "id": "synthetics_sessions", + "_application_id": "app-dst", + } + + resource = { + "id": "synthetics_sessions", + "type": "permanent_retention_filters", + "attributes": {"cross_product_sampling": {"enabled": True, "sample_rate": 0.5}}, + "_application_id": "app-dst", + } + _id, data = _run(rum.update_resource("synthetics_sessions", resource)) + + assert _id == "synthetics_sessions" + dest.patch.assert_awaited_once() + patch_url, patch_payload = dest.patch.await_args.args + assert patch_url == "/api/v2/rum/applications/app-dst/retention_filters/permanent/synthetics_sessions" + assert patch_payload["data"]["id"] == "synthetics_sessions" + assert patch_payload["data"]["type"] == "permanent_retention_filters" + + +def test_delete_resource_is_noop(): + rum = RUMPermanentRetentionFilters(MagicMock()) + rum.config.destination_client = AsyncMock() + rum.config.logger = MagicMock() + _run(rum.delete_resource("synthetics_sessions")) + rum.config.destination_client.delete.assert_not_awaited() + + +def test_connect_resources_remaps_application_id(): + rum = RUMPermanentRetentionFilters(MagicMock()) + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_applications"]["app-src"] = {"id": "app-dst"} + rum.config.skip_failed_resource_connections = False + rum.config.logger = MagicMock() + + resource = { + "id": "synthetics_sessions", + "type": "permanent_retention_filters", + "attributes": {"cross_product_sampling": {"enabled": True, "sample_rate": 0.5}}, + "_application_id": "app-src", + } + rum.connect_resources("synthetics_sessions", resource) + assert resource["_application_id"] == "app-dst" From 7b75f5c2ee31d10f8a6884c0ef3984927383806f Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:51:17 -0400 Subject: [PATCH 2/5] fix(rum): use composite key for rum_permanent_retention_filters BLOCKING fix per review: permanent filter IDs are fixed/system-defined (synthetics_sessions, rum_apm_flat_sampling, forced_replay_sessions) and repeat under every application. Using resource['id'] as the state key caused collisions when multiple apps were synced -- only the last app survived. Fix: use a composite key '{application_id}:{filter_id}' for state indexing. The payload data.id remains the server filter id. update_resource now uses the resource's id and _application_id directly (the id is fixed across orgs, and _application_id was remapped by connect_resources) instead of looking up state, simplifying the first-create path. --- .../model/rum_permanent_retention_filters.py | 61 +++++++++++------ .../test_rum_permanent_retention_filters.py | 67 ++++++++++++------- 2 files changed, 83 insertions(+), 45 deletions(-) diff --git a/datadog_sync/model/rum_permanent_retention_filters.py b/datadog_sync/model/rum_permanent_retention_filters.py index 9a185ce1..442cba54 100644 --- a/datadog_sync/model/rum_permanent_retention_filters.py +++ b/datadog_sync/model/rum_permanent_retention_filters.py @@ -22,6 +22,11 @@ class RUMPermanentRetentionFilters(BaseResource): ``create_resource`` delegates to ``update_resource`` and ``delete_resource`` is a no-op, mirroring ``logs_archives_order``. + Because the filter ids are fixed and repeat under every application, the + state key is a **composite** ``"{application_id}:{filter_id}"`` to avoid + collisions when multiple applications are synced. The payload ``data.id`` + remains the server filter id. + Like ``rum_retention_filters``, the application id is not part of the filter body, so a synthetic ``_application_id`` is injected during enumeration and remapped via ``resource_connections``. It is kept out of ``excluded_attributes`` @@ -49,6 +54,10 @@ class RUMPermanentRetentionFilters(BaseResource): # Additional RUMPermanentRetentionFilters specific attributes _applications_path = "/api/v2/rum/applications" + @staticmethod + def _composite_key(application_id: str, filter_id: str) -> str: + return f"{application_id}:{filter_id}" + async def get_resources(self, client: CustomClient) -> List[Dict]: apps = (await client.get(self._applications_path))["data"] resources: List[Dict] = [] @@ -64,23 +73,34 @@ async def import_resource(self, _id: Optional[str] = None, resource: Optional[Di if _id: # The {permanent_rf_id} GET is parent-scoped; search apps for it. # Only used by --id-file (not allowlisted for this type). + # _id may be a composite key "{app_id}:{filter_id}" or just a + # filter id (legacy). When it's a composite key, look up directly. source_client = self.config.source_client - apps = (await source_client.get(self._applications_path))["data"] - resource = None - for app in apps: - app_id = app["id"] - resp = await source_client.get(f"{self._applications_path}/{app_id}/retention_filters/permanent") - for f in resp["data"]: - if f["id"] == _id: - f["_application_id"] = app_id - resource = f + if ":" in _id: + app_id, filter_id = _id.split(":", 1) + resp = await source_client.get( + f"{self._applications_path}/{app_id}/retention_filters/permanent/{filter_id}" + ) + resource = resp["data"] + resource["_application_id"] = app_id + else: + apps = (await source_client.get(self._applications_path))["data"] + resource = None + for app in apps: + app_id = app["id"] + resp = await source_client.get(f"{self._applications_path}/{app_id}/retention_filters/permanent") + for f in resp["data"]: + if f["id"] == _id: + f["_application_id"] = app_id + resource = f + break + if resource: break - if resource: - break - if resource is None: - raise Exception(f"rum_permanent_retention_filter {_id} not found in any application") + if resource is None: + raise Exception(f"rum_permanent_retention_filter {_id} not found in any application") - return resource["id"], resource + resource = resource # type: ignore[assignment] + return self._composite_key(resource["_application_id"], resource["id"]), resource async def pre_resource_action_hook(self, _id, resource: Dict) -> None: pass @@ -96,18 +116,17 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client app_id = resource.pop("_application_id", None) - destination_state = self.config.state.destination[self.resource_type][_id] - # permanent filter ids are fixed across orgs; the destination id == _id - destination_id = destination_state.get("id", _id) - dest_app_id = destination_state.get("_application_id", app_id) - resource["id"] = destination_id + # permanent filter ids are fixed across orgs; the filter id in the + # resource IS the destination filter id. The app_id was remapped by + # connect_resources to the destination app id. + filter_id = resource["id"] payload = {"data": resource} resp = await destination_client.patch( - f"{self._applications_path}/{dest_app_id}/retention_filters/permanent/{destination_id}", + f"{self._applications_path}/{app_id}/retention_filters/permanent/{filter_id}", payload, ) data = resp["data"] - data["_application_id"] = dest_app_id + data["_application_id"] = app_id return _id, data async def delete_resource(self, _id: str) -> None: diff --git a/tests/unit/test_rum_permanent_retention_filters.py b/tests/unit/test_rum_permanent_retention_filters.py index 6a6ec213..060df378 100644 --- a/tests/unit/test_rum_permanent_retention_filters.py +++ b/tests/unit/test_rum_permanent_retention_filters.py @@ -9,7 +9,10 @@ Permanent RUM retention filters are system-defined with fixed ids (``rum_apm_flat_sampling``, ``synthetics_sessions``, ``forced_replay_sessions``) that are identical across orgs, so the filter id needs no remapping — only the -parent application id does. The endpoint set is PATCH-only (no POST/DELETE), so +parent application id does. Because the filter ids are fixed and repeat under +every application, the state key is a **composite** +``"{application_id}:{filter_id}"`` to avoid collisions when multiple +applications are synced. The endpoint set is PATCH-only (no POST/DELETE), so ``create_resource`` delegates to ``update_resource`` and ``delete_resource`` is a no-op, mirroring ``logs_archives_order``. """ @@ -29,7 +32,7 @@ def _run(coro): loop.close() -_APPS = {"data": [{"id": "app-src"}]} +_APPS = {"data": [{"id": "app-src"}, {"id": "app-other"}]} _PERM = { "data": [ { @@ -47,27 +50,52 @@ def _run(coro): def test_get_resources_iterates_apps_and_injects_application_id(): + import copy + rum = RUMPermanentRetentionFilters(MagicMock()) client = AsyncMock() - client.get = AsyncMock(side_effect=[_APPS, _PERM]) + # Two apps, each returning the same permanent filter (separate copies so + # _application_id injection doesn't overwrite the same dict) + client.get = AsyncMock(side_effect=[_APPS, copy.deepcopy(_PERM), copy.deepcopy(_PERM)]) resources = _run(rum.get_resources(client)) - assert len(resources) == 1 + # Both apps have the same filter id, but different _application_id + assert len(resources) == 2 assert resources[0]["id"] == "synthetics_sessions" assert resources[0]["_application_id"] == "app-src" - assert client.get.await_count == 2 + assert resources[1]["id"] == "synthetics_sessions" + assert resources[1]["_application_id"] == "app-other" -def test_import_resource_passthrough(): +def test_import_resource_uses_composite_key(): + """import_resource returns a composite key '{app_id}:{filter_id}' to avoid + collisions when multiple apps have the same fixed filter id.""" rum = RUMPermanentRetentionFilters(MagicMock()) rum.config.source_client = AsyncMock() resource = _PERM["data"][0] | {"_application_id": "app-src"} _id, data = _run(rum.import_resource(resource=resource)) - assert _id == "synthetics_sessions" + assert _id == "app-src:synthetics_sessions" assert data is resource +def test_import_resource_composite_key_distinguishes_apps(): + """Two resources with the same filter id but different app ids produce + different composite keys.""" + rum = RUMPermanentRetentionFilters(MagicMock()) + rum.config.source_client = AsyncMock() + + r1 = _PERM["data"][0] | {"_application_id": "app-a"} + r2 = _PERM["data"][0] | {"_application_id": "app-b"} + + id1, _ = _run(rum.import_resource(resource=r1)) + id2, _ = _run(rum.import_resource(resource=r2)) + + assert id1 != id2 + assert id1 == "app-a:synthetics_sessions" + assert id2 == "app-b:synthetics_sessions" + + def test_create_resource_delegates_to_update(): rum = RUMPermanentRetentionFilters(MagicMock()) dest = AsyncMock() @@ -75,12 +103,6 @@ def test_create_resource_delegates_to_update(): return_value={"data": {"id": "synthetics_sessions", "type": "permanent_retention_filters", "attributes": {}}} ) rum.config.destination_client = dest - rum.config.state = MagicMock() - rum.config.state.destination = defaultdict(dict) - rum.config.state.destination["rum_permanent_retention_filters"]["synthetics_sessions"] = { - "id": "synthetics_sessions", - "_application_id": "app-dst", - } resource = { "id": "synthetics_sessions", @@ -88,7 +110,8 @@ def test_create_resource_delegates_to_update(): "attributes": {"cross_product_sampling": {"enabled": True, "sample_rate": 0.5}}, "_application_id": "app-dst", } - _id, data = _run(rum.create_resource("synthetics_sessions", resource)) + composite = "app-src:synthetics_sessions" + _id, data = _run(rum.create_resource(composite, resource)) # create delegates to update (no POST endpoint) dest.patch.assert_awaited_once() @@ -96,6 +119,7 @@ def test_create_resource_delegates_to_update(): dest.patch.await_args.args[0] == "/api/v2/rum/applications/app-dst/retention_filters/permanent/synthetics_sessions" ) + assert _id == composite def test_update_resource_patches_permanent_subpath(): @@ -105,12 +129,6 @@ def test_update_resource_patches_permanent_subpath(): return_value={"data": {"id": "synthetics_sessions", "type": "permanent_retention_filters", "attributes": {}}} ) rum.config.destination_client = dest - rum.config.state = MagicMock() - rum.config.state.destination = defaultdict(dict) - rum.config.state.destination["rum_permanent_retention_filters"]["synthetics_sessions"] = { - "id": "synthetics_sessions", - "_application_id": "app-dst", - } resource = { "id": "synthetics_sessions", @@ -118,9 +136,10 @@ def test_update_resource_patches_permanent_subpath(): "attributes": {"cross_product_sampling": {"enabled": True, "sample_rate": 0.5}}, "_application_id": "app-dst", } - _id, data = _run(rum.update_resource("synthetics_sessions", resource)) + composite = "app-src:synthetics_sessions" + _id, data = _run(rum.update_resource(composite, resource)) - assert _id == "synthetics_sessions" + assert _id == composite dest.patch.assert_awaited_once() patch_url, patch_payload = dest.patch.await_args.args assert patch_url == "/api/v2/rum/applications/app-dst/retention_filters/permanent/synthetics_sessions" @@ -132,7 +151,7 @@ def test_delete_resource_is_noop(): rum = RUMPermanentRetentionFilters(MagicMock()) rum.config.destination_client = AsyncMock() rum.config.logger = MagicMock() - _run(rum.delete_resource("synthetics_sessions")) + _run(rum.delete_resource("app-src:synthetics_sessions")) rum.config.destination_client.delete.assert_not_awaited() @@ -150,5 +169,5 @@ def test_connect_resources_remaps_application_id(): "attributes": {"cross_product_sampling": {"enabled": True, "sample_rate": 0.5}}, "_application_id": "app-src", } - rum.connect_resources("synthetics_sessions", resource) + rum.connect_resources("app-src:synthetics_sessions", resource) assert resource["_application_id"] == "app-dst" From 3fa05f039e985c1a973d14575956d71d10c581d7 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Mon, 28 Sep 2026 10:07:16 -0400 Subject: [PATCH 3/5] fix(rum): keep _application_id in diff for permanent retention filters Per review: excluding _application_id from the diff hid a real parent remap. If a destination RUM application is deleted and recreated, the parent ID changes but persisted state still references the old ID; the exclusion made the sync skip the PATCH, leaving the new application's permanent filters at their defaults. Now _application_id participates in the diff so a changed parent mapping forces the PATCH. Added regression test verifying _application_id is not in exclude_regex_paths. --- .../model/rum_permanent_retention_filters.py | 6 +++--- tests/unit/test_rum_permanent_retention_filters.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/datadog_sync/model/rum_permanent_retention_filters.py b/datadog_sync/model/rum_permanent_retention_filters.py index 442cba54..057e39c1 100644 --- a/datadog_sync/model/rum_permanent_retention_filters.py +++ b/datadog_sync/model/rum_permanent_retention_filters.py @@ -30,8 +30,9 @@ class RUMPermanentRetentionFilters(BaseResource): Like ``rum_retention_filters``, the application id is not part of the filter body, so a synthetic ``_application_id`` is injected during enumeration and remapped via ``resource_connections``. It is kept out of ``excluded_attributes`` - (must survive ``prep_resource``) and excluded from diffs via - ``deep_diff_config.exclude_regex_paths``. + (must survive ``prep_resource``) and is intentionally kept IN the diff so a + changed parent mapping (e.g. destination app deleted and recreated with a + new id) forces a PATCH rather than silently skipping it. """ resource_type = "rum_permanent_retention_filters" @@ -47,7 +48,6 @@ class RUMPermanentRetentionFilters(BaseResource): }, deep_diff_config={ "ignore_order": True, - "exclude_regex_paths": [r".*\['_application_id'\]"], }, skip_resource_mapping=True, ) diff --git a/tests/unit/test_rum_permanent_retention_filters.py b/tests/unit/test_rum_permanent_retention_filters.py index 060df378..00aea63e 100644 --- a/tests/unit/test_rum_permanent_retention_filters.py +++ b/tests/unit/test_rum_permanent_retention_filters.py @@ -171,3 +171,15 @@ def test_connect_resources_remaps_application_id(): } rum.connect_resources("app-src:synthetics_sessions", resource) assert resource["_application_id"] == "app-dst" + + +def test_application_id_not_excluded_from_diff(): + """Regression test: _application_id must participate in the diff so a + changed parent mapping (e.g. destination app deleted and recreated) forces + a PATCH rather than being silently skipped.""" + rum = RUMPermanentRetentionFilters(MagicMock()) + exclude_paths = rum.resource_config.deep_diff_config.get("exclude_regex_paths", []) + assert not any("_application_id" in p for p in exclude_paths), ( + "_application_id must NOT be in deep_diff_config.exclude_regex_paths " + "so a changed parent mapping forces a PATCH" + ) From 5dc8a3818acad6c75f5a47076d69bf4e8fd4142d Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 11:53:59 -0400 Subject: [PATCH 4/5] fix(rum): respect editability.trace_editable for permanent filters Per integration test findings (EU1 -> US5 sync): rum_apm_flat_sampling (400 'trace fields are not editable for the rum_apm_flat_sampling filter'): the API rejects PATCHes that include cross_product_sampling.trace_sample_rate or trace_enabled when editability.trace_editable is false. update_resource now checks the editability.trace_editable flag and strips trace_sample_rate and trace_enabled from cross_product_sampling when the flag is false, so only non-trace attributes are sent in the PATCH body. Two new tests: one verifying trace fields are stripped when trace_editable=false, one verifying they are kept when trace_editable=true. --- .../model/rum_permanent_retention_filters.py | 14 +++++ .../test_rum_permanent_retention_filters.py | 59 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/datadog_sync/model/rum_permanent_retention_filters.py b/datadog_sync/model/rum_permanent_retention_filters.py index 057e39c1..ca37723d 100644 --- a/datadog_sync/model/rum_permanent_retention_filters.py +++ b/datadog_sync/model/rum_permanent_retention_filters.py @@ -120,6 +120,20 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: # resource IS the destination filter id. The app_id was remapped by # connect_resources to the destination app id. filter_id = resource["id"] + + # Respect the editability.trace_editable flag: when false, the API + # rejects PATCHes that include cross_product_sampling.trace_sample_rate + # or trace_enabled (400 'trace fields are not editable'). Strip those + # fields from the PATCH body so only non-trace attributes are sent. + editability = resource.get("attributes", {}).get("editability", {}) + if not editability.get("trace_editable", True): + cps = resource.get("attributes", {}).get("cross_product_sampling", {}) + if cps: + cps.pop("trace_sample_rate", None) + cps.pop("trace_enabled", None) + if not cps: + resource["attributes"].pop("cross_product_sampling", None) + payload = {"data": resource} resp = await destination_client.patch( f"{self._applications_path}/{app_id}/retention_filters/permanent/{filter_id}", diff --git a/tests/unit/test_rum_permanent_retention_filters.py b/tests/unit/test_rum_permanent_retention_filters.py index 00aea63e..2396c384 100644 --- a/tests/unit/test_rum_permanent_retention_filters.py +++ b/tests/unit/test_rum_permanent_retention_filters.py @@ -147,6 +147,65 @@ def test_update_resource_patches_permanent_subpath(): assert patch_payload["data"]["type"] == "permanent_retention_filters" +def test_update_resource_strips_trace_fields_when_not_editable(): + """When editability.trace_editable is false, the API rejects PATCHes that + include cross_product_sampling.trace_sample_rate or trace_enabled. The + model must strip those fields from the PATCH body.""" + rum = RUMPermanentRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock( + return_value={"data": {"id": "rum_apm_flat_sampling", "type": "permanent_retention_filters", "attributes": {}}} + ) + rum.config.destination_client = dest + + resource = { + "id": "rum_apm_flat_sampling", + "type": "permanent_retention_filters", + "attributes": { + "cross_product_sampling": {"trace_sample_rate": 100, "trace_enabled": True}, + "editability": {"trace_editable": False}, + "name": "RUM APM Flat Sampling", + }, + "_application_id": "app-dst", + } + composite = "app-src:rum_apm_flat_sampling" + _run(rum.update_resource(composite, resource)) + + patch_payload = dest.patch.await_args.args[1] + # trace fields must be stripped from cross_product_sampling + cps = patch_payload["data"]["attributes"].get("cross_product_sampling", {}) + assert "trace_sample_rate" not in cps + assert "trace_enabled" not in cps + + +def test_update_resource_keeps_trace_fields_when_editable(): + """When editability.trace_editable is true (or absent), trace fields are + kept in the PATCH body.""" + rum = RUMPermanentRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock( + return_value={"data": {"id": "synthetics_sessions", "type": "permanent_retention_filters", "attributes": {}}} + ) + rum.config.destination_client = dest + + resource = { + "id": "synthetics_sessions", + "type": "permanent_retention_filters", + "attributes": { + "cross_product_sampling": {"trace_sample_rate": 50, "trace_enabled": True}, + "editability": {"trace_editable": True}, + "name": "Synthetics Sessions", + }, + "_application_id": "app-dst", + } + _run(rum.update_resource("app-src:synthetics_sessions", resource)) + + patch_payload = dest.patch.await_args.args[1] + cps = patch_payload["data"]["attributes"].get("cross_product_sampling", {}) + assert cps.get("trace_sample_rate") == 50 + assert cps.get("trace_enabled") is True + + def test_delete_resource_is_noop(): rum = RUMPermanentRetentionFilters(MagicMock()) rum.config.destination_client = AsyncMock() From 46857dbef3df7dad89d4a34149b3b2fbecffb29b Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 14:13:07 -0400 Subject: [PATCH 5/5] fix(rum): keep editability in resource for update_resource to read Per integration test re-test: attributes.editability was in excluded_attributes, so prep_resource stripped it before update_resource could read editability.trace_editable. The trace fields were never stripped, causing 400 on rum_apm_flat_sampling (trace_editable=false). Fix: removed attributes.editability from excluded_attributes so it survives prep_resource. Added editability to deep_diff_config.exclude_regex_paths so it doesn't cause a perpetual diff (it's read-only). Two new regression tests verify editability is not excluded but is excluded from diffs. --- .../model/rum_permanent_retention_filters.py | 11 +++++++- .../test_rum_permanent_retention_filters.py | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/datadog_sync/model/rum_permanent_retention_filters.py b/datadog_sync/model/rum_permanent_retention_filters.py index ca37723d..a4c577ea 100644 --- a/datadog_sync/model/rum_permanent_retention_filters.py +++ b/datadog_sync/model/rum_permanent_retention_filters.py @@ -41,13 +41,22 @@ class RUMPermanentRetentionFilters(BaseResource): excluded_attributes=[ "attributes.name", "attributes.description", - "attributes.editability", + # NOTE: attributes.editability is deliberately NOT excluded here. + # update_resource needs to read editability.trace_editable to decide + # whether to strip trace fields from the PATCH body. If excluded, + # prep_resource strips it before update_resource runs, and the + # trace fields are never stripped (causing a 400 on + # rum_apm_flat_sampling where trace_editable=false). ], resource_connections={ "rum_applications": ["_application_id"], }, deep_diff_config={ "ignore_order": True, + # editability is read-only (returned by the API but not updatable). + # It must survive prep_resource (so update_resource can read + # trace_editable), but should not participate in diffs. + "exclude_regex_paths": [r".*\['editability'\]"], }, skip_resource_mapping=True, ) diff --git a/tests/unit/test_rum_permanent_retention_filters.py b/tests/unit/test_rum_permanent_retention_filters.py index 2396c384..0a0df058 100644 --- a/tests/unit/test_rum_permanent_retention_filters.py +++ b/tests/unit/test_rum_permanent_retention_filters.py @@ -242,3 +242,28 @@ def test_application_id_not_excluded_from_diff(): "_application_id must NOT be in deep_diff_config.exclude_regex_paths " "so a changed parent mapping forces a PATCH" ) + + +def test_editability_not_in_excluded_attributes(): + """Regression test: attributes.editability must NOT be in + excluded_attributes because update_resource needs to read + editability.trace_editable to decide whether to strip trace fields. + If excluded, prep_resource strips it before update_resource runs.""" + rum = RUMPermanentRetentionFilters(MagicMock()) + excluded = rum.resource_config.excluded_attributes or [] + assert not any("editability" in a for a in excluded), ( + "attributes.editability must NOT be in excluded_attributes so it " + "survives prep_resource and is available in update_resource" + ) + + +def test_editability_excluded_from_diff(): + """editability is read-only (returned by the API but not updatable), so it + must be excluded from diffs to avoid a perpetual diff loop. It must survive + prep_resource (not in excluded_attributes) but be excluded from + comparison (in deep_diff_config.exclude_regex_paths).""" + rum = RUMPermanentRetentionFilters(MagicMock()) + exclude_paths = rum.resource_config.deep_diff_config.get("exclude_regex_paths", []) + assert any("editability" in p for p in exclude_paths), ( + "editability must be in deep_diff_config.exclude_regex_paths to avoid " "a perpetual diff loop (it's read-only)" + )