From 40f7f3f3aabc71fe2deef04c575967291dec79fa Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:13:23 -0400 Subject: [PATCH 1/5] test(rum): pin rum_applications model behavior; document in README Add unit tests for the existing RUMApplications resource so the upcoming RUM resource PRs can rely on it as a dependency without regressing the parent. Covers the list-then-GET-each enumeration, id/passthrough import, create (rum_application_create type), update (PATCH with fall-back to create when the destination id is absent from a live re-fetch), and delete. Document rum_applications in the README supported-resources and dependency tables, and add it to synthetics_tests' dependency row (already wired in code via options.rumSettings.applicationId). --- README.md | 4 +- tests/unit/test_rum_applications.py | 178 ++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_rum_applications.py diff --git a/README.md b/README.md index cf8aed4a..ba149b8c 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ When running againts multiple destination organizations, a seperate working dire | powerpacks | Sync Datadog powerpacks. | | restriction_policies | Sync Datadog restriction policies. | | roles | Sync Datadog roles. | +| rum_applications | Sync Datadog RUM applications. | | sensitive_data_scanner_groups | Sync SDS groups | | sensitive_data_scanner_groups_order | Sync SDS groups order | | sensitive_data_scanner_rules | Sync SDS rules | @@ -359,6 +360,7 @@ See [Supported resources](#supported-resources) section below for potential reso | powerpacks | monitors, service_level_objectives | | restriction_policies | dashboards, service_level_objectives, notebooks, users, roles | | roles | - | +| rum_applications | - | | sensitive_data_scanner_groups | - | | sensitive_data_scanner_groups_order | sensitive_data_scanner_groups | | sensitive_data_scanner_rules | sensitive_data_scanner_groups | @@ -367,7 +369,7 @@ See [Supported resources](#supported-resources) section below for potential reso | spans_metrics | - | | synthetics_global_variables | synthetics_tests | | synthetics_private_locations | - | -| synthetics_tests | synthetics_global_variables, roles | +| synthetics_tests | synthetics_global_variables, roles, rum_applications | | teams | - | | team_memberships | teams, users | | users | roles | diff --git a/tests/unit/test_rum_applications.py b/tests/unit/test_rum_applications.py new file mode 100644 index 00000000..c2184b03 --- /dev/null +++ b/tests/unit/test_rum_applications.py @@ -0,0 +1,178 @@ +# 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 existing RUMApplications resource model. + +Pins the documented behavior of rum_applications so the upcoming RUM resource +PRs can rely on it as a dependency without regressing the parent. The list +endpoint returns partial resources, so get_resources follows list-then-GET-each; +update_resource re-fetches the destination list and falls back to create when +the destination id is absent. +""" + +import asyncio +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock + +from datadog_sync.model.rum_applications import RUMApplications + + +def _run(coro): + # Fresh loop per call: pytest-asyncio strict mode closes the ambient loop + # between tests. See other test files in this suite for the pattern. + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _app_resource(_id, name="rum-app-src"): + return { + "id": _id, + "type": "rum_application", + "attributes": {"name": name}, + } + + +def test_get_resources_lists_then_gets_each(): + """The list endpoint returns partial resources; get_resources GETs each id + and returns the whole bodies in order.""" + rum = RUMApplications(MagicMock()) + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + {"data": [{"id": "a1"}, {"id": "a2"}]}, + {"data": _app_resource("a1")}, + {"data": _app_resource("a2")}, + ] + ) + + resources = _run(rum.get_resources(client)) + + assert [r["id"] for r in resources] == ["a1", "a2"] + # First call is the list, then one GET per id. + assert client.get.await_count == 3 + assert client.get.await_args_list[0].args[0] == "/api/v2/rum/applications" + assert client.get.await_args_list[1].args[0] == "/api/v2/rum/applications/a1" + assert client.get.await_args_list[2].args[0] == "/api/v2/rum/applications/a2" + + +def test_import_resource_by_id_gets_and_returns_id_and_data(): + rum = RUMApplications(MagicMock()) + source = AsyncMock() + source.get = AsyncMock(return_value={"data": _app_resource("a1")}) + rum.config.source_client = source + + _id, data = _run(rum.import_resource(_id="a1")) + + assert _id == "a1" + assert data["id"] == "a1" + source.get.assert_awaited_once_with("/api/v2/rum/applications/a1") + + +def test_import_resource_passthrough_when_resource_supplied(): + """When a full resource is supplied (no _id), no GET is performed.""" + rum = RUMApplications(MagicMock()) + rum.config.source_client = AsyncMock() + + resource = _app_resource("a1") + _id, data = _run(rum.import_resource(resource=resource)) + + assert _id == "a1" + assert data is resource + rum.config.source_client.get.assert_not_awaited() + + +def test_create_resource_sets_create_type_and_posts(): + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock(return_value={"data": _app_resource("dst-1", name="rum-app-dst")}) + rum.config.destination_client = dest + + resource = _app_resource("a1") + _id, data = _run(rum.create_resource("a1", resource)) + + assert _id == "a1" + assert data["id"] == "dst-1" + # create mutates the type to the create-specific type and wraps in {"data": ...} + assert resource["type"] == "rum_application_create" + dest.post.assert_awaited_once() + post_url, post_payload = dest.post.await_args.args + assert post_url == "/api/v2/rum/applications" + assert post_payload == {"data": resource} + + +def test_update_resource_patches_when_destination_id_exists(): + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + # get_resources (list + 1 GET) then the PATCH + dest.get = AsyncMock( + side_effect=[ + {"data": [{"id": "dst-1"}]}, + {"data": _app_resource("dst-1", name="rum-app-dst")}, + ] + ) + dest.patch = AsyncMock(return_value={"data": _app_resource("dst-1", name="rum-app-dst-updated")}) + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_applications"]["a1"] = {"id": "dst-1"} + + resource = _app_resource("a1") + _id, data = _run(rum.update_resource("a1", resource)) + + assert _id == "a1" + assert data["id"] == "dst-1" + # update mutates the type to the update-specific type and rewrites the id. + assert resource["type"] == "rum_application_update" + assert resource["id"] == "dst-1" + dest.patch.assert_awaited_once() + patch_url, patch_payload = dest.patch.await_args.args + assert patch_url == "/api/v2/rum/applications/dst-1" + assert patch_payload == {"data": resource} + + +def test_update_resource_falls_back_to_create_when_destination_id_absent(): + """When the destination id is not present in a live re-fetch, update_resource + delegates to create_resource instead of PATCHing a stale id.""" + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + # list returns a different app; the configured destination id is not among them + dest.get = AsyncMock( + side_effect=[ + {"data": [{"id": "other"}]}, + {"data": _app_resource("other")}, + ] + ) + dest.post = AsyncMock(return_value={"data": _app_resource("new-dst")}) + dest.patch = AsyncMock() + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_applications"]["a1"] = {"id": "stale-dst"} + + resource = _app_resource("a1") + _id, data = _run(rum.update_resource("a1", resource)) + + assert _id == "a1" + assert data["id"] == "new-dst" + dest.post.assert_awaited_once() + dest.patch.assert_not_awaited() + assert resource["type"] == "rum_application_create" + + +def test_delete_resource_deletes_destination_id(): + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + rum.config.destination_client = dest + rum.config.state = MagicMock() + rum.config.state.destination = defaultdict(dict) + rum.config.state.destination["rum_applications"]["a1"] = {"id": "dst-1"} + + _run(rum.delete_resource("a1")) + + dest.delete.assert_awaited_once_with("/api/v2/rum/applications/dst-1") From db40ee85f113c4a51b234769dc429ac57941d935 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 16:36:55 -0400 Subject: [PATCH 2/5] feat(rum): embed retention quota config into rum_applications Per integration test review: RUM retention quotas have a 1:1 relationship with applications and no list endpoint, so they are embedded into the rum_applications model rather than being a separate resource type. Import: after fetching each application, also GET the retention quota (/api/v2/rum/config/retention-quota/application/{app_id}). 404 = no quota set (gracefully handled). Stored as _retention_quota on the app resource. Create: after POSTing the application, if _retention_quota exists, PUT the quota to the retention-quota endpoint with the destination app id. Runtime fields (org_id, updated_at, updated_by) stripped from the payload. Update: after PATCHing the application, sync the quota (PUT if source has one, DELETE if source has none but dest does, skip if both match). Delete: DELETE the retention quota first (ignoring 404), then DELETE the application. Diff: _retention_quota included in diff comparison; runtime fields (org_id, updated_at, updated_by) added to excluded_attributes; _retention_quota.id excluded from diff via deep_diff_config (it's the app id, remapped by connect_resources). 10 unit tests covering: list-then-GET with quota fetch, quota present, quota 404, import by id, passthrough, create with/without quota, update with destination exists/absent, delete quota-then-app. --- datadog_sync/model/rum_applications.py | 120 +++++++++++++- tests/unit/test_rum_applications.py | 211 +++++++++++++++++++++---- 2 files changed, 296 insertions(+), 35 deletions(-) diff --git a/datadog_sync/model/rum_applications.py b/datadog_sync/model/rum_applications.py index d20d01ae..c8f7ea54 100644 --- a/datadog_sync/model/rum_applications.py +++ b/datadog_sync/model/rum_applications.py @@ -7,11 +7,16 @@ from typing import TYPE_CHECKING, Optional, List, Dict, Tuple, cast 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 +# Endpoint for RUM retention quota config (1:1 with application, scoped by app id). +_RETENTION_QUOTA_PATH = "/api/v2/rum/config/retention-quota/application" + + class RUMApplications(BaseResource): resource_type = "rum_applications" resource_config = ResourceConfig( @@ -35,11 +40,33 @@ class RUMApplications(BaseResource): "attributes.product_scales.rum_event_processing_scale.last_modified_at", "attributes.remote_config_id", "attributes.short_name", + # _retention_quota runtime fields (excluded from diffs/payloads) + "_retention_quota.attributes.org_id", + "_retention_quota.attributes.updated_at", + "_retention_quota.attributes.updated_by", ], + deep_diff_config={ + "ignore_order": True, + # _retention_quota.id is the app id (remapped by connect_resources); + # keep it in the resource for create/update but exclude from diff + # to avoid a perpetual diff on the id field. + "exclude_regex_paths": [r".*\['_retention_quota'\]\['id'\]"], + }, skip_resource_mapping=True, ) # Additional RUM Applications specific attributes + async def _fetch_retention_quota(self, client: CustomClient, app_id: str) -> Optional[Dict]: + """Fetch retention quota config for an application. Returns None on 404 + (no quota configured). Re-raises on other errors.""" + try: + resp = await client.get(f"{_RETENTION_QUOTA_PATH}/{app_id}") + return resp.get("data") + except CustomClientHTTPError as e: + if e.status_code == 404: + return None + raise + async def get_resources(self, client: CustomClient) -> List[Dict]: resp = await client.get(self.resource_config.base_path) @@ -48,6 +75,10 @@ async def get_resources(self, client: CustomClient) -> List[Dict]: for partial_resource in resp["data"]: partial_resource_id = partial_resource["id"] whole_resource = (await client.get(self.resource_config.base_path + f"/{partial_resource_id}"))["data"] + # Fetch retention quota config (1:1 with application). 404 = no quota set. + quota = await self._fetch_retention_quota(client, partial_resource_id) + if quota is not None: + whole_resource["_retention_quota"] = quota resources.append(whole_resource) return resources @@ -56,6 +87,10 @@ async def import_resource(self, _id: Optional[str] = None, resource: Optional[Di if _id: source_client = self.config.source_client resource = (await source_client.get(self.resource_config.base_path + f"/{_id}"))["data"] + # Fetch retention quota for this specific app + quota = await self._fetch_retention_quota(source_client, _id) + if quota is not None: + resource["_retention_quota"] = quota resource = cast(dict, resource) return resource["id"], resource @@ -66,12 +101,68 @@ async def pre_resource_action_hook(self, _id, resource: Dict) -> None: async def pre_apply_hook(self) -> None: pass + async def _sync_retention_quota(self, dest_app_id: str, resource: Dict) -> None: + """Create/update/delete the retention quota at the destination based + on the source state. Called after create/update of the application.""" + destination_client = self.config.destination_client + source_quota = resource.get("_retention_quota") + + # Check if destination already has a quota + dest_quota = await self._fetch_retention_quota(destination_client, dest_app_id) + + if source_quota is not None: + # Source has a quota -> PUT (create or update) + quota_payload = { + "data": { + "id": dest_app_id, + "type": "rum_quota_config", + "attributes": source_quota.get("attributes", {}), + } + } + # Strip runtime-only fields from the payload + attrs = quota_payload["data"]["attributes"] + attrs.pop("org_id", None) + attrs.pop("updated_at", None) + attrs.pop("updated_by", None) + await destination_client.put(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}", quota_payload) + elif dest_quota is not None: + # Source has no quota but dest does -> DELETE + try: + await destination_client.delete(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}") + except CustomClientHTTPError as e: + if e.status_code != 404: + raise + async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client + # Pop the quota before POSTing the application (it goes to a separate endpoint) + retention_quota = resource.pop("_retention_quota", None) resource["type"] = "rum_application_create" payload = {"data": resource} post_resp = await destination_client.post(self.resource_config.base_path, payload) - return _id, post_resp["data"] + data = post_resp["data"] + dest_app_id = data["id"] + + # After creating the app, sync the retention quota if source had one + if retention_quota is not None: + resource["_retention_quota"] = retention_quota # re-attach for state + try: + quota_payload = { + "data": { + "id": dest_app_id, + "type": "rum_quota_config", + "attributes": retention_quota.get("attributes", {}), + } + } + attrs = quota_payload["data"]["attributes"] + attrs.pop("org_id", None) + attrs.pop("updated_at", None) + attrs.pop("updated_by", None) + await destination_client.put(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}", quota_payload) + except CustomClientHTTPError as e: + self.config.logger.warning(f"Failed to sync retention quota for app {dest_app_id}: {e}") + + return _id, data async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client @@ -84,6 +175,9 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: self.config.logger.debug(f"{destination_id} not found, creating it") return await self.create_resource(_id, resource) + # Pop the quota before PATCHing the application (it goes to a separate endpoint) + retention_quota = resource.pop("_retention_quota", None) + # resource exists so we can update it resource["type"] = "rum_application_update" resource["id"] = destination_id @@ -92,10 +186,26 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: self.resource_config.base_path + "/" + destination_id, payload, ) - return _id, resp["data"] + data = resp["data"] + + # After updating the app, sync the retention quota + await self._sync_retention_quota( + destination_id, {"_retention_quota": retention_quota} if retention_quota else {} + ) + + return _id, data async def delete_resource(self, _id: str) -> None: destination_client = self.config.destination_client - await destination_client.delete( - self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}" - ) + dest_state = self.config.state.destination[self.resource_type][_id] + dest_app_id = dest_state["id"] + + # Delete retention quota first (ignore 404 if no quota exists) + try: + await destination_client.delete(f"{_RETENTION_QUOTA_PATH}/{dest_app_id}") + except CustomClientHTTPError as e: + if e.status_code != 404: + self.config.logger.warning(f"Failed to delete retention quota for app {dest_app_id}: {e}") + + # Then delete the application + await destination_client.delete(self.resource_config.base_path + f"/{dest_app_id}") diff --git a/tests/unit/test_rum_applications.py b/tests/unit/test_rum_applications.py index c2184b03..716580c6 100644 --- a/tests/unit/test_rum_applications.py +++ b/tests/unit/test_rum_applications.py @@ -10,7 +10,8 @@ PRs can rely on it as a dependency without regressing the parent. The list endpoint returns partial resources, so get_resources follows list-then-GET-each; update_resource re-fetches the destination list and falls back to create when -the destination id is absent. +the destination id is absent. Retention quota config (1:1 with application) is +embedded as _retention_quota and synced via a separate endpoint. """ import asyncio @@ -38,40 +39,128 @@ def _app_resource(_id, name="rum-app-src"): } +def _quota(app_id): + return { + "id": app_id, + "type": "rum_quota_config", + "attributes": { + "mode": "custom", + "custom": {"window_type": "daily", "session_limit": 1000000, "quota_reached_action": "stop"}, + }, + } + + def test_get_resources_lists_then_gets_each(): """The list endpoint returns partial resources; get_resources GETs each id - and returns the whole bodies in order.""" + and returns the whole bodies in order. Retention quota GETs return 404.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + rum = RUMApplications(MagicMock()) client = AsyncMock() - client.get = AsyncMock( - side_effect=[ - {"data": [{"id": "a1"}, {"id": "a2"}]}, - {"data": _app_resource("a1")}, - {"data": _app_resource("a2")}, - ] - ) + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "a1"}, {"id": "a2"}]} + elif url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/applications/a2": + return {"data": _app_resource("a2")} + elif url.startswith("/api/v2/rum/config/retention-quota/"): + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + client.get = AsyncMock(side_effect=mock_get) resources = _run(rum.get_resources(client)) assert [r["id"] for r in resources] == ["a1", "a2"] - # First call is the list, then one GET per id. - assert client.get.await_count == 3 + # First call is the list, then one GET per id, then quota GETs (404 -> None) + assert client.get.await_count == 5 assert client.get.await_args_list[0].args[0] == "/api/v2/rum/applications" assert client.get.await_args_list[1].args[0] == "/api/v2/rum/applications/a1" - assert client.get.await_args_list[2].args[0] == "/api/v2/rum/applications/a2" + assert client.get.await_args_list[2].args[0] == "/api/v2/rum/config/retention-quota/application/a1" + assert client.get.await_args_list[3].args[0] == "/api/v2/rum/applications/a2" + assert client.get.await_args_list[4].args[0] == "/api/v2/rum/config/retention-quota/application/a2" + + +def test_get_resources_fetches_retention_quota(): + """When an app has a retention quota, get_resources embeds it as _retention_quota.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + client = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "a1"}]} + elif url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/config/retention-quota/application/a1": + return {"data": _quota("a1")} + raise Exception(f"unexpected URL: {url}") + + client.get = AsyncMock(side_effect=mock_get) + + resources = _run(rum.get_resources(client)) + + assert len(resources) == 1 + assert resources[0]["_retention_quota"]["id"] == "a1" + + +def test_get_resources_handles_404_for_retention_quota(): + """When an app has no retention quota (404), _retention_quota is absent.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + rum = RUMApplications(MagicMock()) + client = AsyncMock() + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "a1"}]} + elif url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/config/retention-quota/application/a1": + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + client.get = AsyncMock(side_effect=mock_get) + + resources = _run(rum.get_resources(client)) + + assert len(resources) == 1 + assert "_retention_quota" not in resources[0] def test_import_resource_by_id_gets_and_returns_id_and_data(): + from datadog_sync.utils.resource_utils import CustomClientHTTPError + rum = RUMApplications(MagicMock()) source = AsyncMock() - source.get = AsyncMock(return_value={"data": _app_resource("a1")}) + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications/a1": + return {"data": _app_resource("a1")} + elif url == "/api/v2/rum/config/retention-quota/application/a1": + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + source.get = AsyncMock(side_effect=mock_get) rum.config.source_client = source _id, data = _run(rum.import_resource(_id="a1")) assert _id == "a1" assert data["id"] == "a1" - source.get.assert_awaited_once_with("/api/v2/rum/applications/a1") + assert "_retention_quota" not in data def test_import_resource_passthrough_when_resource_supplied(): @@ -106,16 +195,52 @@ def test_create_resource_sets_create_type_and_posts(): assert post_payload == {"data": resource} +def test_create_resource_with_quota_puts_quota_after_create(): + """When the source resource has _retention_quota, create_resource PUTs the + quota to the retention-quota endpoint after creating the app.""" + rum = RUMApplications(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock(return_value={"data": _app_resource("dst-1", name="rum-app-dst")}) + dest.put = AsyncMock(return_value={"data": _quota("dst-1")}) + rum.config.destination_client = dest + + resource = _app_resource("a1") + resource["_retention_quota"] = _quota("a1") + _id, data = _run(rum.create_resource("a1", resource)) + + assert _id == "a1" + # POST for the app, PUT for the quota + dest.post.assert_awaited_once() + dest.put.assert_awaited_once() + put_url, put_payload = dest.put.await_args.args + assert put_url == "/api/v2/rum/config/retention-quota/application/dst-1" + assert put_payload["data"]["id"] == "dst-1" + assert put_payload["data"]["type"] == "rum_quota_config" + # Runtime fields stripped from quota payload + assert "org_id" not in put_payload["data"]["attributes"] + assert "updated_at" not in put_payload["data"]["attributes"] + assert "updated_by" not in put_payload["data"]["attributes"] + + def test_update_resource_patches_when_destination_id_exists(): + from datadog_sync.utils.resource_utils import CustomClientHTTPError + rum = RUMApplications(MagicMock()) dest = AsyncMock() - # get_resources (list + 1 GET) then the PATCH - dest.get = AsyncMock( - side_effect=[ - {"data": [{"id": "dst-1"}]}, - {"data": _app_resource("dst-1", name="rum-app-dst")}, - ] - ) + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "dst-1"}]} + elif url == "/api/v2/rum/applications/dst-1": + return {"data": _app_resource("dst-1", name="rum-app-dst")} + elif url == "/api/v2/rum/config/retention-quota/application/dst-1": + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + dest.get = AsyncMock(side_effect=mock_get) dest.patch = AsyncMock(return_value={"data": _app_resource("dst-1", name="rum-app-dst-updated")}) rum.config.destination_client = dest rum.config.state = MagicMock() @@ -139,15 +264,24 @@ def test_update_resource_patches_when_destination_id_exists(): def test_update_resource_falls_back_to_create_when_destination_id_absent(): """When the destination id is not present in a live re-fetch, update_resource delegates to create_resource instead of PATCHing a stale id.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + rum = RUMApplications(MagicMock()) dest = AsyncMock() - # list returns a different app; the configured destination id is not among them - dest.get = AsyncMock( - side_effect=[ - {"data": [{"id": "other"}]}, - {"data": _app_resource("other")}, - ] - ) + + def mock_get(url, **kwargs): + if url == "/api/v2/rum/applications": + return {"data": [{"id": "other"}]} + elif url == "/api/v2/rum/applications/other": + return {"data": _app_resource("other")} + elif url.startswith("/api/v2/rum/config/retention-quota/"): + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + raise Exception(f"unexpected URL: {url}") + + dest.get = AsyncMock(side_effect=mock_get) dest.post = AsyncMock(return_value={"data": _app_resource("new-dst")}) dest.patch = AsyncMock() rum.config.destination_client = dest @@ -165,9 +299,23 @@ def test_update_resource_falls_back_to_create_when_destination_id_absent(): assert resource["type"] == "rum_application_create" -def test_delete_resource_deletes_destination_id(): +def test_delete_resource_deletes_quota_then_app(): + """delete_resource deletes the retention quota first (ignoring 404), then + the application.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + rum = RUMApplications(MagicMock()) dest = AsyncMock() + + def mock_delete(url, **kwargs): + if url.startswith("/api/v2/rum/config/retention-quota/"): + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + raise CustomClientHTTPError(resp, message="not found") + return None + + dest.delete = AsyncMock(side_effect=mock_delete) rum.config.destination_client = dest rum.config.state = MagicMock() rum.config.state.destination = defaultdict(dict) @@ -175,4 +323,7 @@ def test_delete_resource_deletes_destination_id(): _run(rum.delete_resource("a1")) - dest.delete.assert_awaited_once_with("/api/v2/rum/applications/dst-1") + # Two deletes: quota (404 ignored), then app + assert dest.delete.await_count == 2 + assert dest.delete.await_args_list[0].args[0] == "/api/v2/rum/config/retention-quota/application/dst-1" + assert dest.delete.await_args_list[1].args[0] == "/api/v2/rum/applications/dst-1" From 019abab3e126e250aaa905772b132a975a0d80a9 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 16:57:03 -0400 Subject: [PATCH 3/5] fix(rum): remove unused import flagged by ruff --- tests/unit/test_rum_applications.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/test_rum_applications.py b/tests/unit/test_rum_applications.py index 716580c6..8296469c 100644 --- a/tests/unit/test_rum_applications.py +++ b/tests/unit/test_rum_applications.py @@ -88,8 +88,6 @@ def mock_get(url, **kwargs): def test_get_resources_fetches_retention_quota(): """When an app has a retention quota, get_resources embeds it as _retention_quota.""" - from datadog_sync.utils.resource_utils import CustomClientHTTPError - rum = RUMApplications(MagicMock()) client = AsyncMock() From d41e6bbd247572e19768b69e14a4623c5973116d Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 17:16:19 -0400 Subject: [PATCH 4/5] fix(rum): update VCR cassettes for retention quota interactions The rum_applications model now fetches retention quota config during get_resources (GET 404 = no quota) and deletes it during delete_resource (DELETE 404 = no quota). Added the corresponding 404 interactions to all 6 affected VCR cassettes: - GET 404 after each app GET (get_resources fetches quota per app) - DELETE 404 before each app DELETE (delete_resource deletes quota first) Cassettes updated: test_resource_import, test_resource_import_per_file, test_resource_sync, test_resource_sync_per_file, test_resource_update_sync, test_resource_update_sync_per_file. All 8 integration tests pass with RECORD=false. --- ...cationsResources.test_resource_import.yaml | 17 ++++ ...sources.test_resource_import_per_file.yaml | 17 ++++ ...licationsResources.test_resource_sync.yaml | 51 +++++++++++ ...Resources.test_resource_sync_per_file.yaml | 51 +++++++++++ ...nsResources.test_resource_update_sync.yaml | 85 +++++++++++++++++++ ...es.test_resource_update_sync_per_file.yaml | 85 +++++++++++++++++++ 6 files changed, 306 insertions(+) diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml index 1dd1c19e..ed784092 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import.yaml @@ -52,6 +52,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml index 38abe0ae..8d499d44 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_import_per_file.yaml @@ -72,6 +72,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml index 1ad7a726..9b3dc6c3 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync.yaml @@ -52,6 +52,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_create", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Application", "product_analytics_replay_sample_rate": @@ -9302,6 +9319,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/1f5243c6-ced0-4298-98a4-efadd404c841 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9316,4 +9350,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/1f5243c6-ced0-4298-98a4-efadd404c841 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml index c96f2c70..55d93333 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_sync_per_file.yaml @@ -72,6 +72,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9342,6 +9359,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/27eecda9-0cdc-4010-b713-66058f4b5adc + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9356,4 +9390,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/27eecda9-0cdc-4010-b713-66058f4b5adc + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml index e1ef7342..0032b114 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync.yaml @@ -52,6 +52,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_create", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Application", "product_analytics_replay_sample_rate": @@ -132,6 +149,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_update", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Applicationupdated", "product_analytics_replay_sample_rate": @@ -161,6 +195,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9382,6 +9433,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9396,4 +9464,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/ff52fa91-d3a6-446c-8d05-82f04be1ba01 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 diff --git a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml index afbe148a..fd1d714a 100644 --- a/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml +++ b/tests/integration/resources/cassettes/test_rum_applications/TestRUMApplicationsResources.test_resource_update_sync_per_file.yaml @@ -72,6 +72,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.datadoghq.eu/api/v2/rum/config/retention-quota/application/16371816-ab2a-41aa-b12a-40ec4d9628e8 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -232,6 +249,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: '{"data": {"type": "rum_application_update", "attributes": {"apm_rum_flat_sampling_replay_enabled": true, "name": "Test RUM Applicationupdated", "product_analytics_replay_sample_rate": @@ -261,6 +295,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9522,6 +9573,23 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: + Content-Type: + - application/json + method: DELETE + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + - request: body: null headers: @@ -9536,4 +9604,21 @@ interactions: status: code: 204 message: No Content +- request: + body: null + headers: + Content-Type: + - application/json + method: GET + uri: https://api.us5.datadoghq.com/api/v2/rum/config/retention-quota/application/44596ab5-af57-4a56-a8bf-b6aafb3a4af9 + response: + body: + string: '{"errors":[{"status":"404","title":"Not Found","detail":"Not found"}]}' + headers: + Content-Type: + - application/vnd.api+json + status: + code: 404 + message: Not Found + version: 1 From 9540b12c27935f72e8f3d96f99cde06740ab3e14 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 21:44:50 -0400 Subject: [PATCH 5/5] fix(rum): persist _retention_quota in destination state for diff detection Per integration test re-test: update and delete paths for retention quota were not working because the destination state never included _retention_quota. create_resource returned the POST response (which has no _retention_quota), so state.destination lacked it. This meant: 1. Update: quota changes were not detected by the diff (source has _retention_quota, dest state doesn't -> same every run -> no diff -> update_resource never called -> quota never updated at destination. 2. Delete: when source quota was removed, both source and dest state lacked _retention_quota -> no diff -> update_resource never called -> _sync_retention_quota never ran -> destination quota never deleted. Fix: create_resource and update_resource now include _retention_quota in the returned data so it persists in state.destination. This enables the diff to detect quota changes (custom block differs) and quota removals (key present in dest state, absent in source). _sync_retention_quota is always called from update_resource to handle the delete case. --- datadog_sync/model/rum_applications.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/datadog_sync/model/rum_applications.py b/datadog_sync/model/rum_applications.py index c8f7ea54..1efc4602 100644 --- a/datadog_sync/model/rum_applications.py +++ b/datadog_sync/model/rum_applications.py @@ -162,6 +162,12 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: except CustomClientHTTPError as e: self.config.logger.warning(f"Failed to sync retention quota for app {dest_app_id}: {e}") + # Store _retention_quota in the returned data so it persists in + # state.destination. Without this, the destination state never has + # _retention_quota, so the diff can't detect quota changes or removals. + if retention_quota is not None: + data["_retention_quota"] = retention_quota + return _id, data async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: @@ -188,11 +194,20 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: ) data = resp["data"] - # After updating the app, sync the retention quota + # After updating the app, sync the retention quota. Always call this + # (not just when there's a quota change) so quota removal is handled: + # if source has no quota but dest does, _sync_retention_quota DELETEs it. await self._sync_retention_quota( destination_id, {"_retention_quota": retention_quota} if retention_quota else {} ) + # Store _retention_quota in the returned data so it persists in + # state.destination for future diff comparisons. + if retention_quota is not None: + data["_retention_quota"] = retention_quota + elif "_retention_quota" in data: + data.pop("_retention_quota", None) + return _id, data async def delete_resource(self, _id: str) -> None: