From fffeb91701faa1495677bd03f12076138d9d4a9c Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:20:24 -0400 Subject: [PATCH 1/4] feat(rum): add rum_retention_filters resource Add the rum_retention_filters resource type, unifying the generic retention_filters and exclusion_filters types (distinguished by data.type) which live on separate sub-paths under /api/v2/rum/applications/{app_id}. Retention filters are parent-scoped under a RUM application, but the application id is not part of the filter body. The model injects a synthetic _application_id during enumeration and remaps it (source app id -> destination app id) via resource_connections before apply. prep_resource runs after connect_resources and removes excluded_attributes, so _application_id is deliberately NOT excluded (it must survive prep so create/update can read it) and is instead kept out of diffs via deep_diff_config.exclude_regex_paths (same pattern as users.py's handle/service_account fields). - datadog_sync/model/rum_retention_filters.py (new) -- RUMRetentionFilters - datadog_sync/models/__init__.py -- register RUMRetentionFilters - tests/unit/test_rum_retention_filters.py (new) -- 9 unit tests pinning get_resources (app iteration + retention/exclusion merge + _application_id injection), import passthrough, create (per-type sub-path, id popped, _application_id re-attached), update (destination id + app id from state), delete (per-type sub-path), and connect_resources app-id remap. - README.md -- add rum_retention_filters (depends on rum_applications) Order syncing is handled by a separate rum_retention_filters_order resource (follow-up PR), matching the codebase convention (logs_archives_order, sensitive_data_scanner_groups_order, etc.) since order must be applied after filters exist at the destination. Integration tests + VCR cassettes are deferred (require sandbox-org API access to record). --- README.md | 2 + datadog_sync/model/rum_retention_filters.py | 148 +++++++++++ datadog_sync/models/__init__.py | 1 + tests/unit/test_rum_retention_filters.py | 272 ++++++++++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 datadog_sync/model/rum_retention_filters.py create mode 100644 tests/unit/test_rum_retention_filters.py diff --git a/README.md b/README.md index 884064e4..7214da25 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_retention_filters | Sync Datadog RUM retention filters (generic + exclusion). | | sensitive_data_scanner_groups | Sync SDS groups | | sensitive_data_scanner_groups_order | Sync SDS groups order | | sensitive_data_scanner_rules | Sync SDS rules | @@ -363,6 +364,7 @@ See [Supported resources](#supported-resources) section below for potential reso | roles | - | | rum_applications | - | | rum_metrics | - | +| rum_retention_filters | rum_applications | | sensitive_data_scanner_groups | - | | sensitive_data_scanner_groups_order | sensitive_data_scanner_groups | | sensitive_data_scanner_rules | sensitive_data_scanner_groups | diff --git a/datadog_sync/model/rum_retention_filters.py b/datadog_sync/model/rum_retention_filters.py new file mode 100644 index 00000000..74afa6da --- /dev/null +++ b/datadog_sync/model/rum_retention_filters.py @@ -0,0 +1,148 @@ +# 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 RUMRetentionFilters(BaseResource): + """RUM retention filters, parent-scoped under a RUM application. + + The application id is not part of the filter body, so the model injects a + synthetic ``_application_id`` during enumeration and remaps it (source app + id -> destination app id) via ``resource_connections`` before apply. + ``prep_resource`` runs after ``connect_resources`` and removes + ``excluded_attributes``, so ``_application_id`` is deliberately NOT in + ``excluded_attributes`` (it must survive prep so create/update can read it) + and is instead kept out of diffs via ``deep_diff_config.exclude_regex_paths``. + + The model unifies the generic ``retention_filters`` type and the + ``exclusion_filters`` type, which live on separate sub-paths + (``/retention_filters`` vs ``/retention_filters/exclusion``) and are + distinguished by ``data.type``. + """ + + resource_type = "rum_retention_filters" + resource_config = ResourceConfig( + base_path="/api/v2/rum/applications", + excluded_attributes=[ + "id", + ], + resource_connections={ + "rum_applications": ["_application_id"], + }, + deep_diff_config={ + "ignore_order": True, + "exclude_regex_paths": [r".*\['_application_id'\]"], + }, + skip_resource_mapping=True, + ) + # Additional RUMRetentionFilters specific attributes + _applications_path = "/api/v2/rum/applications" + + def _subpath(self, resource: Dict) -> str: + if resource.get("type") == "exclusion_filters": + return "/retention_filters/exclusion" + return "/retention_filters" + + 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"] + # generic retention filters + resp = await client.get(f"{self._applications_path}/{app_id}/retention_filters") + for f in resp["data"]: + f["_application_id"] = app_id + resources.append(f) + # exclusion filters (separate sub-path) + excl = await client.get(f"{self._applications_path}/{app_id}/retention_filters/exclusion") + for f in excl["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 {rf_id} GET is parent-scoped; without the app id we must search. + # This path is only used by --id-file (not allowlisted for this type), + # so the cost is acceptable. The normal import flow supplies a full + # resource from get_resources (passthrough). + 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") + for f in resp["data"]: + if f["id"] == _id: + f["_application_id"] = app_id + resource = f + break + if resource: + break + excl = await source_client.get(f"{self._applications_path}/{app_id}/retention_filters/exclusion") + for f in excl["data"]: + if f["id"] == _id: + f["_application_id"] = app_id + resource = f + break + if resource: + break + if resource is None: + raise Exception(f"rum_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]: + destination_client = self.config.destination_client + app_id = resource.pop("_application_id", None) + # create data has no id (server-assigned) + resource.pop("id", None) + subpath = self._subpath(resource) + payload = {"data": resource} + resp = await destination_client.post(f"{self._applications_path}/{app_id}{subpath}", payload) + data = resp["data"] + # re-attach so state.destination can build future update/delete URLs + data["_application_id"] = app_id + return _id, data + + 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] + destination_id = destination_state["id"] + # prefer the destination app id recorded at create time; fall back to the + # (already-remapped) resource value if state lacks it + dest_app_id = destination_state.get("_application_id", app_id) + resource["id"] = destination_id + subpath = self._subpath(resource) + payload = {"data": resource} + resp = await destination_client.patch( + f"{self._applications_path}/{dest_app_id}{subpath}/{destination_id}", + payload, + ) + data = resp["data"] + data["_application_id"] = dest_app_id + return _id, data + + async def delete_resource(self, _id: str) -> None: + destination_client = self.config.destination_client + destination_state = self.config.state.destination[self.resource_type][_id] + destination_id = destination_state["id"] + dest_app_id = destination_state["_application_id"] + subpath = self._subpath(destination_state) + await destination_client.delete(f"{self._applications_path}/{dest_app_id}{subpath}/{destination_id}") diff --git a/datadog_sync/models/__init__.py b/datadog_sync/models/__init__.py index eb515d9f..7ff82bec 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_retention_filters import RUMRetentionFilters from datadog_sync.model.security_monitoring_rules import SecurityMonitoringRules from datadog_sync.model.sensitive_data_scanner_groups import SensitiveDataScannerGroups from datadog_sync.model.sensitive_data_scanner_groups_order import SensitiveDataScannerGroupsOrder diff --git a/tests/unit/test_rum_retention_filters.py b/tests/unit/test_rum_retention_filters.py new file mode 100644 index 00000000..58c3e2bd --- /dev/null +++ b/tests/unit/test_rum_retention_filters.py @@ -0,0 +1,272 @@ +# 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 RUMRetentionFilters resource model. + +RUM retention filters are parent-scoped under a RUM application +(``/api/v2/rum/applications/{app_id}/retention_filters``). The application id is +not part of the filter body, so the model injects a synthetic ``_application_id`` +during enumeration and remaps it (source app id -> destination app id) via +``resource_connections`` before apply. ``prep_resource`` runs after +``connect_resources`` and removes ``excluded_attributes``, so ``_application_id`` +is kept out of ``excluded_attributes`` (it must survive prep so create/update can +read it) and is instead excluded from diffs via ``deep_diff_config``. + +The model unifies the generic ``retention_filters`` type and the +``exclusion_filters`` type, which live on separate sub-paths +(``/retention_filters`` vs ``/retention_filters/exclusion``) and are +distinguished by ``data.type``. +""" + +import asyncio +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock + +from datadog_sync.model.rum_retention_filters import RUMRetentionFilters + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +_APPS = {"data": [{"id": "app-src"}, {"id": "app-other"}]} + +_RETENTION = { + "data": [ + { + "id": "rf-1", + "type": "retention_filters", + "attributes": {"name": "keep-views", "query": "@type:view", "enabled": True, "sample_rate": 1.0}, + } + ] +} + +_EXCLUSION = { + "data": [ + { + "id": "ef-1", + "type": "exclusion_filters", + "attributes": {"name": "drop-errors", "query": "@type:error", "enabled": True}, + "meta": {}, + } + ] +} + + +def _client_with_apps_and_filters(): + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _APPS, # list apps + _RETENTION, # app-src retention filters + _EXCLUSION, # app-src exclusion filters + {"data": []}, # app-other retention filters + {"data": []}, # app-other exclusion filters + ] + ) + return client + + +def test_get_resources_iterates_apps_and_merges_retention_and_exclusion(): + rum = RUMRetentionFilters(MagicMock()) + client = _client_with_apps_and_filters() + + resources = _run(rum.get_resources(client)) + + ids = [(r["id"], r["type"], r["_application_id"]) for r in resources] + assert ("rf-1", "retention_filters", "app-src") in ids + assert ("ef-1", "exclusion_filters", "app-src") in ids + # 5 GETs: 1 apps list + 2 per app (retention + exclusion) for 2 apps + assert client.get.await_count == 5 + + +def test_import_resource_passthrough_when_resource_supplied(): + rum = RUMRetentionFilters(MagicMock()) + rum.config.source_client = AsyncMock() + + resource = { + "id": "rf-1", + "type": "retention_filters", + "attributes": {"name": "keep-views"}, + "_application_id": "app-src", + } + _id, data = _run(rum.import_resource(resource=resource)) + + assert _id == "rf-1" + assert data is resource + rum.config.source_client.get.assert_not_awaited() + + +def test_create_resource_retention_type_posts_to_generic_path(): + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock( + return_value={"data": {"id": "rf-dst", "type": "retention_filters", "attributes": {"name": "keep-views"}}} + ) + rum.config.destination_client = dest + + resource = { + "id": "rf-1", + "type": "retention_filters", + "attributes": {"name": "keep-views"}, + "_application_id": "app-dst", + } + _id, data = _run(rum.create_resource("rf-1", resource)) + + assert _id == "rf-1" + assert data["id"] == "rf-dst" + # _application_id is re-attached to the response so state can build future URLs + assert data["_application_id"] == "app-dst" + # create data has no id; it must be popped before POST + assert "id" not in resource + dest.post.assert_awaited_once() + post_url, post_payload = dest.post.await_args.args + assert post_url == "/api/v2/rum/applications/app-dst/retention_filters" + assert post_payload == {"data": {"type": "retention_filters", "attributes": {"name": "keep-views"}}} + + +def test_create_resource_exclusion_type_posts_to_exclusion_subpath(): + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock( + return_value={"data": {"id": "ef-dst", "type": "exclusion_filters", "attributes": {"name": "drop-errors"}}} + ) + rum.config.destination_client = dest + + resource = { + "id": "ef-1", + "type": "exclusion_filters", + "attributes": {"name": "drop-errors"}, + "_application_id": "app-dst", + } + _id, data = _run(rum.create_resource("ef-1", resource)) + + assert data["id"] == "ef-dst" + assert data["_application_id"] == "app-dst" + post_url = dest.post.await_args.args[0] + assert post_url == "/api/v2/rum/applications/app-dst/retention_filters/exclusion" + + +def test_update_resource_patches_destination_id_on_correct_subpath(): + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock( + return_value={"data": {"id": "rf-dst", "type": "retention_filters", "attributes": {"name": "keep-views"}}} + ) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_retention_filters"]["rf-1"] = { + "id": "rf-dst", + "_application_id": "app-dst", + } + + resource = { + "id": "rf-1", + "type": "retention_filters", + "attributes": {"name": "keep-views-updated"}, + "_application_id": "app-dst", + } + _id, data = _run(rum.update_resource("rf-1", resource)) + + assert _id == "rf-1" + # update sets the body id to the destination id and uses the destination app id in the URL + assert resource["id"] == "rf-dst" + 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/rf-dst" + assert patch_payload == { + "data": {"type": "retention_filters", "attributes": {"name": "keep-views-updated"}, "id": "rf-dst"} + } + + +def test_update_resource_exclusion_uses_exclusion_subpath(): + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock( + return_value={"data": {"id": "ef-dst", "type": "exclusion_filters", "attributes": {"name": "drop-errors"}}} + ) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_retention_filters"]["ef-1"] = { + "id": "ef-dst", + "_application_id": "app-dst", + } + + resource = { + "id": "ef-1", + "type": "exclusion_filters", + "attributes": {"name": "drop-errors-updated"}, + "_application_id": "app-dst", + } + _run(rum.update_resource("ef-1", resource)) + + patch_url = dest.patch.await_args.args[0] + assert patch_url == "/api/v2/rum/applications/app-dst/retention_filters/exclusion/ef-dst" + + +def test_delete_resource_deletes_destination_id_on_correct_subpath(): + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_retention_filters"]["rf-1"] = { + "id": "rf-dst", + "type": "retention_filters", + "_application_id": "app-dst", + } + + _run(rum.delete_resource("rf-1")) + + dest.delete.assert_awaited_once_with("/api/v2/rum/applications/app-dst/retention_filters/rf-dst") + + +def test_delete_resource_exclusion_uses_exclusion_subpath(): + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_retention_filters"]["ef-1"] = { + "id": "ef-dst", + "type": "exclusion_filters", + "_application_id": "app-dst", + } + + _run(rum.delete_resource("ef-1")) + + dest.delete.assert_awaited_once_with("/api/v2/rum/applications/app-dst/retention_filters/exclusion/ef-dst") + + +def test_connect_resources_remaps_application_id_to_destination(): + """connect_resources remaps the synthetic _application_id from the source app + id to the destination app id using state.destination['rum_applications'].""" + rum = RUMRetentionFilters(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() + # BaseResource.__init__ sets skip_resource_mapping; connect_resources uses + # resource_connections declared on the config. + resource = { + "id": "rf-1", + "type": "retention_filters", + "attributes": {"name": "keep-views"}, + "_application_id": "app-src", + } + + result = rum.connect_resources("rf-1", resource) + + assert resource["_application_id"] == "app-dst" + # no failed connections -> empty result + assert result.empty_binding_escalation is False From 380100c95d1f286630c58096674d6de6e3f40201 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Mon, 28 Sep 2026 10:04:27 -0400 Subject: [PATCH 2/4] fix(rum): add destination reconciliation to rum_retention_filters create Per review: skip_resource_mapping=True means the apply pre-pass never lists destination filters, so create_resource always POSTs when state is absent. Before POSTing, check if a matching filter already exists at the destination (scoped by app + type + name) and adopt it via update instead of creating a duplicate or failing with a conflict. New test verifies the reconciliation path. --- datadog_sync/model/rum_retention_filters.py | 21 +++++++++++++ tests/unit/test_rum_retention_filters.py | 35 +++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/datadog_sync/model/rum_retention_filters.py b/datadog_sync/model/rum_retention_filters.py index 74afa6da..44d9e7a1 100644 --- a/datadog_sync/model/rum_retention_filters.py +++ b/datadog_sync/model/rum_retention_filters.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Optional, List, Dict, Tuple from datadog_sync.utils.base_resource import BaseResource, ResourceConfig +from datadog_sync.utils.resource_utils import CustomClientHTTPError if TYPE_CHECKING: from datadog_sync.utils.custom_client import CustomClient @@ -113,6 +114,26 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: # create data has no id (server-assigned) resource.pop("id", None) subpath = self._subpath(resource) + + # Destination reconciliation: skip_resource_mapping=True means the apply + # pre-pass never lists destination filters, so create_resource always + # runs when state is absent. Before POSTing, check if a matching filter + # already exists at the destination (scoped by app + type + name) and + # adopt it via update instead of creating a duplicate. + filter_type = resource.get("type", "") + filter_name = resource.get("attributes", {}).get("name", "") + try: + existing = await destination_client.get(f"{self._applications_path}/{app_id}{subpath}") + for f in existing.get("data", []): + if f.get("type") == filter_type and f.get("attributes", {}).get("name") == filter_name: + # Hydrate state so update_resource can resolve the PATCH URL + f["_application_id"] = app_id + self.config.state.destination[self.resource_type][_id] = f + return await self.update_resource(_id, resource) + except CustomClientHTTPError as e: + if e.status_code != 404: + raise + payload = {"data": resource} resp = await destination_client.post(f"{self._applications_path}/{app_id}{subpath}", payload) data = resp["data"] diff --git a/tests/unit/test_rum_retention_filters.py b/tests/unit/test_rum_retention_filters.py index 58c3e2bd..7af4c14a 100644 --- a/tests/unit/test_rum_retention_filters.py +++ b/tests/unit/test_rum_retention_filters.py @@ -107,6 +107,8 @@ def test_import_resource_passthrough_when_resource_supplied(): def test_create_resource_retention_type_posts_to_generic_path(): rum = RUMRetentionFilters(MagicMock()) dest = AsyncMock() + # Reconciliation GET returns no matching filter + dest.get = AsyncMock(return_value={"data": []}) dest.post = AsyncMock( return_value={"data": {"id": "rf-dst", "type": "retention_filters", "attributes": {"name": "keep-views"}}} ) @@ -132,9 +134,42 @@ def test_create_resource_retention_type_posts_to_generic_path(): assert post_payload == {"data": {"type": "retention_filters", "attributes": {"name": "keep-views"}}} +def test_create_resource_reconciles_existing_destination_filter(): + """When a matching filter already exists at the destination (same app + type + + name), create_resource hydrates state and delegates to update instead of + POSTing a duplicate.""" + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + existing = {"id": "rf-existing", "type": "retention_filters", "attributes": {"name": "keep-views"}} + dest.get = AsyncMock(return_value={"data": [existing]}) + dest.patch = AsyncMock( + return_value={"data": {"id": "rf-existing", "type": "retention_filters", "attributes": {"name": "keep-views"}}} + ) + dest.post = AsyncMock() + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + + resource = { + "id": "rf-1", + "type": "retention_filters", + "attributes": {"name": "keep-views"}, + "_application_id": "app-dst", + } + _id, data = _run(rum.create_resource("rf-1", resource)) + + assert _id == "rf-1" + dest.post.assert_not_awaited() + dest.patch.assert_awaited_once() + # state was hydrated with the existing destination filter + assert rum.config.state.destination["rum_retention_filters"]["rf-1"]["id"] == "rf-existing" + + def test_create_resource_exclusion_type_posts_to_exclusion_subpath(): rum = RUMRetentionFilters(MagicMock()) dest = AsyncMock() + # Reconciliation GET returns no matching filter + dest.get = AsyncMock(return_value={"data": []}) dest.post = AsyncMock( return_value={"data": {"id": "ef-dst", "type": "exclusion_filters", "attributes": {"name": "drop-errors"}}} ) From f6700c40803a6b371425e1e4000576e6183b1861 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 11:53:04 -0400 Subject: [PATCH 3/4] fix(rum): handle system-provisioned default filters and error_tracking filter Per integration test findings (EU1 -> US5 sync): 1. default_sessions / default_errors (400 'attribute source must be one of ui terraform'): the 'source' field is a runtime-only response attribute not in the OpenAPI spec. Added 'attributes.source' to excluded_attributes so it is stripped by prep_resource before the PATCH. 2. error_tracking_exclusion_filter (400 'only enabled can be updated on the error tracking exclusion filter'): the API only allows toggling 'enabled' on this special exclusion filter. update_resource now strips all attributes except 'enabled' when the destination id matches 'error_tracking_exclusion_filter'. New test verifies only 'enabled' is sent for the error_tracking filter. --- datadog_sync/model/rum_retention_filters.py | 13 ++++++++ tests/unit/test_rum_retention_filters.py | 36 +++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/datadog_sync/model/rum_retention_filters.py b/datadog_sync/model/rum_retention_filters.py index 44d9e7a1..3c477c85 100644 --- a/datadog_sync/model/rum_retention_filters.py +++ b/datadog_sync/model/rum_retention_filters.py @@ -35,6 +35,10 @@ class RUMRetentionFilters(BaseResource): base_path="/api/v2/rum/applications", excluded_attributes=[ "id", + # 'source' is a runtime-only response field (not in the OpenAPI + # spec) that the API rejects on update with 400 for system-provisioned + # default filters (default_sessions, default_errors). + "attributes.source", ], resource_connections={ "rum_applications": ["_application_id"], @@ -47,6 +51,9 @@ class RUMRetentionFilters(BaseResource): ) # Additional RUMRetentionFilters specific attributes _applications_path = "/api/v2/rum/applications" + # The error_tracking exclusion filter only allows toggling 'enabled'; + # all other attributes are read-only on update. + _error_tracking_filter_id = "error_tracking_exclusion_filter" def _subpath(self, resource: Dict) -> str: if resource.get("type") == "exclusion_filters": @@ -151,6 +158,12 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: dest_app_id = destination_state.get("_application_id", app_id) resource["id"] = destination_id subpath = self._subpath(resource) + + # The error_tracking exclusion filter only allows toggling 'enabled'; + # sending other attributes (name, query, event_type) causes a 400. + if destination_id == self._error_tracking_filter_id: + resource["attributes"] = {"enabled": resource.get("attributes", {}).get("enabled")} + payload = {"data": resource} resp = await destination_client.patch( f"{self._applications_path}/{dest_app_id}{subpath}/{destination_id}", diff --git a/tests/unit/test_rum_retention_filters.py b/tests/unit/test_rum_retention_filters.py index 7af4c14a..12b2e7ab 100644 --- a/tests/unit/test_rum_retention_filters.py +++ b/tests/unit/test_rum_retention_filters.py @@ -248,6 +248,42 @@ def test_update_resource_exclusion_uses_exclusion_subpath(): assert patch_url == "/api/v2/rum/applications/app-dst/retention_filters/exclusion/ef-dst" +def test_update_resource_strips_non_enabled_fields_for_error_tracking_filter(): + """The error_tracking_exclusion_filter only allows toggling 'enabled'. + All other attributes (name, query, event_type) must be stripped from the + PATCH body to avoid a 400 'only enabled can be updated' error.""" + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock( + return_value={ + "data": { + "id": "error_tracking_exclusion_filter", + "type": "exclusion_filters", + "attributes": {"enabled": True}, + } + } + ) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_retention_filters"]["etf-1"] = { + "id": "error_tracking_exclusion_filter", + "_application_id": "app-dst", + } + + resource = { + "id": "error_tracking_exclusion_filter", + "type": "exclusion_filters", + "attributes": {"name": "updated-name", "query": "@type:error", "enabled": True, "event_type": "error"}, + "_application_id": "app-dst", + } + _run(rum.update_resource("etf-1", resource)) + + patch_payload = dest.patch.await_args.args[1] + # Only 'enabled' should be in the attributes + assert patch_payload["data"]["attributes"] == {"enabled": True} + + def test_delete_resource_deletes_destination_id_on_correct_subpath(): rum = RUMRetentionFilters(MagicMock()) dest = AsyncMock() From e7798c973b1d92db78f99b3f7d508fc7f73c7d8c Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 14:13:00 -0400 Subject: [PATCH 4/4] fix(rum): strip meta from retention filter PATCH, skip error_tracking delete Per integration test re-test findings: 1. default_sessions/default_errors still failing: 'source' is under 'meta', not 'attributes'. Changed excluded_attributes from 'attributes.source' to 'meta' (the entire meta object contains runtime-only fields: updated_at, updated_by_handle, edition_mode, source). 2. error_tracking_exclusion_filter cannot be deleted (system-provisioned). delete_resource now detects this filter id and skips the delete with a warning instead of failing. New test verifies error_tracking filter delete is skipped. --- datadog_sync/model/rum_retention_filters.py | 22 +++++++++++++++++---- tests/unit/test_rum_retention_filters.py | 21 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/datadog_sync/model/rum_retention_filters.py b/datadog_sync/model/rum_retention_filters.py index 3c477c85..bff61165 100644 --- a/datadog_sync/model/rum_retention_filters.py +++ b/datadog_sync/model/rum_retention_filters.py @@ -35,10 +35,13 @@ class RUMRetentionFilters(BaseResource): base_path="/api/v2/rum/applications", excluded_attributes=[ "id", - # 'source' is a runtime-only response field (not in the OpenAPI - # spec) that the API rejects on update with 400 for system-provisioned - # default filters (default_sessions, default_errors). - "attributes.source", + # 'source' is a runtime-only response field under 'meta' (not + # in the OpenAPI spec) that the API rejects on update with 400 + # for system-provisioned default filters (default_sessions, + # default_errors). The entire 'meta' object contains runtime-only + # fields (updated_at, updated_by_handle, edition_mode, source) + # that should not be sent in create/update requests. + "meta", ], resource_connections={ "rum_applications": ["_application_id"], @@ -179,4 +182,15 @@ async def delete_resource(self, _id: str) -> None: destination_id = destination_state["id"] dest_app_id = destination_state["_application_id"] subpath = self._subpath(destination_state) + + # The error_tracking_exclusion_filter is a system-provisioned filter + # that cannot be deleted via the API. Skip the delete and log a warning + # instead of failing. + if destination_id == self._error_tracking_filter_id: + self.config.logger.warning( + "rum_retention_filters: error_tracking_exclusion_filter cannot be " + "deleted (system-provisioned). Removing from state only." + ) + return + await destination_client.delete(f"{self._applications_path}/{dest_app_id}{subpath}/{destination_id}") diff --git a/tests/unit/test_rum_retention_filters.py b/tests/unit/test_rum_retention_filters.py index 12b2e7ab..dc43884a 100644 --- a/tests/unit/test_rum_retention_filters.py +++ b/tests/unit/test_rum_retention_filters.py @@ -318,6 +318,27 @@ def test_delete_resource_exclusion_uses_exclusion_subpath(): dest.delete.assert_awaited_once_with("/api/v2/rum/applications/app-dst/retention_filters/exclusion/ef-dst") +def test_delete_resource_skips_error_tracking_filter(): + """The error_tracking_exclusion_filter is system-provisioned and cannot be + deleted via the API. delete_resource should skip it and log a warning.""" + rum = RUMRetentionFilters(MagicMock()) + dest = AsyncMock() + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_retention_filters"]["etf-1"] = { + "id": "error_tracking_exclusion_filter", + "type": "exclusion_filters", + "_application_id": "app-dst", + } + rum.config.logger = MagicMock() + + _run(rum.delete_resource("etf-1")) + + dest.delete.assert_not_awaited() + rum.config.logger.warning.assert_called_once() + + def test_connect_resources_remaps_application_id_to_destination(): """connect_resources remaps the synthetic _application_id from the source app id to the destination app id using state.destination['rum_applications']."""