From 75ac39b3672b8d7a26dfbb4aa3ddd146fecd373e Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:30:00 -0400 Subject: [PATCH 1/3] feat(rum): add rum_config resource (singleton) RUM config is a singleton org setting (no DELETE, no per-id path). Only enforced_application_tags is configurable; all other attributes are server-managed and excluded from diffs. create_resource checks whether the destination singleton already exists and delegates to update_resource if so (mirroring logs_archives_order); delete_resource is a no-op. - datadog_sync/model/rum_config.py (new) - datadog_sync/models/__init__.py -- register - tests/unit/test_rum_config.py (new) -- 6 unit tests - README.md -- add rum_config (no dependencies) Integration tests + VCR cassettes deferred (require sandbox-org API access). --- README.md | 2 + datadog_sync/model/rum_config.py | 99 ++++++++++++++++++++++++ datadog_sync/models/__init__.py | 1 + tests/unit/test_rum_config.py | 124 +++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 datadog_sync/model/rum_config.py create mode 100644 tests/unit/test_rum_config.py diff --git a/README.md b/README.md index 1ba4fa20..772f50c8 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ When running againts multiple destination organizations, a seperate working dire | restriction_policies | Sync Datadog restriction policies. | | roles | Sync Datadog roles. | | rum_applications | Sync Datadog RUM applications. | +| rum_config | Sync Datadog RUM configuration (singleton). | | rum_metrics | Sync Datadog RUM-based metrics. | | rum_operations | Sync Datadog RUM operations. | | rum_replay_playlists | Sync Datadog RUM replay playlists (shell only). | @@ -369,6 +370,7 @@ See [Supported resources](#supported-resources) section below for potential reso | restriction_policies | dashboards, service_level_objectives, notebooks, users, roles | | roles | - | | rum_applications | - | +| rum_config | - | | rum_metrics | - | | rum_operations | rum_applications | | rum_replay_playlists | - | diff --git a/datadog_sync/model/rum_config.py b/datadog_sync/model/rum_config.py new file mode 100644 index 00000000..03791440 --- /dev/null +++ b/datadog_sync/model/rum_config.py @@ -0,0 +1,99 @@ +# 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 RUMConfig(BaseResource): + """RUM config (singleton org setting). + + RUM config is a singleton (no DELETE, no per-id path). Only + ``enforced_application_tags`` is configurable; all other attributes are + server-managed and excluded from diffs. ``create_resource`` checks whether + the destination singleton already exists and delegates to ``update_resource`` + if so (mirroring ``logs_archives_order``); ``delete_resource`` is a no-op. + """ + + resource_type = "rum_config" + resource_config = ResourceConfig( + base_path="/api/v2/rum/config", + excluded_attributes=[ + "id", + "attributes.disabled", + "attributes.enforced_application_tags_updated_at", + "attributes.enforced_application_tags_updated_by", + "attributes.ootb_metrics_version", + "attributes.ootb_metrics_version_installed_at", + "attributes.retention_filters_enabled", + "attributes.retention_filters_enabled_updated_at", + "attributes.retention_filters_enabled_updated_by", + ], + concurrent=False, + skip_resource_mapping=True, + ) + # Additional RUMConfig specific attributes + default_id: str = "rum-config" + + async def get_resources(self, client: CustomClient) -> List[Dict]: + resp = await client.get(self.resource_config.base_path) + + return [resp["data"]] + + async def import_resource(self, _id: Optional[str] = None, resource: Optional[Dict] = None) -> Tuple[str, Dict]: + # Singleton: always keyed by the default id. + return self.default_id, resource + + async def pre_resource_action_hook(self, _id, resource: Dict) -> None: + pass + + async def pre_apply_hook(self) -> None: + pass + + async def _existing_destination(self) -> Optional[Dict]: + destination_client = self.config.destination_client + try: + resp = await destination_client.get(self.resource_config.base_path) + return resp["data"] + except Exception as e: + self.config.logger.debug(f"rum_config: destination singleton not present: {e}") + return None + + async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + # If the destination singleton already exists, hydrate state and delegate + # to update (mirrors logs_archives_order). Otherwise POST a new one. + existing = await self._existing_destination() + if existing is not None: + self.config.state.destination[self.resource_type][_id] = existing + return await self.update_resource(_id, resource) + + destination_client = self.config.destination_client + payload = { + "data": { + "type": self.resource_type, + "attributes": {"enforced_application_tags": resource["attributes"]["enforced_application_tags"]}, + } + } + resp = await destination_client.post(self.resource_config.base_path, payload) + return _id, resp["data"] + + async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: + destination_client = self.config.destination_client + payload = { + "data": { + "type": self.resource_type, + "attributes": {"enforced_application_tags": resource["attributes"]["enforced_application_tags"]}, + } + } + resp = await destination_client.patch(self.resource_config.base_path, payload) + return _id, resp["data"] + + async def delete_resource(self, _id: str) -> None: + self.config.logger.warning("rum_config cannot be deleted. Removing resource from state only.") diff --git a/datadog_sync/models/__init__.py b/datadog_sync/models/__init__.py index 924a8786..e79105d0 100644 --- a/datadog_sync/models/__init__.py +++ b/datadog_sync/models/__init__.py @@ -28,6 +28,7 @@ from datadog_sync.model.restriction_policies import RestrictionPolicies from datadog_sync.model.roles import Roles from datadog_sync.model.rum_applications import RUMApplications +from datadog_sync.model.rum_config import RUMConfig from datadog_sync.model.rum_metrics import RUMMetrics from datadog_sync.model.rum_operations import RUMOperations from datadog_sync.model.rum_operation_strong_links import RUMOperationStrongLinks diff --git a/tests/unit/test_rum_config.py b/tests/unit/test_rum_config.py new file mode 100644 index 00000000..40665fbb --- /dev/null +++ b/tests/unit/test_rum_config.py @@ -0,0 +1,124 @@ +# 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 RUMConfig resource model. + +RUM config is a singleton org setting (no DELETE, no per-id path). Only +``enforced_application_tags`` is configurable; all other attributes are +server-managed and excluded from diffs. ``create_resource`` checks whether the +destination singleton already exists and delegates to ``update_resource`` if so +(mirroring ``logs_archives_order``); ``delete_resource`` is a no-op. +""" + +import asyncio +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock + +from datadog_sync.model.rum_config import RUMConfig + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _cfg(enforced=True): + return { + "id": "rum-config", + "type": "rum_config", + "attributes": { + "enforced_application_tags": enforced, + "disabled": False, + "retention_filters_enabled": True, + "ootb_metrics_version": 2, + }, + } + + +def test_get_resources_returns_single_element_list(): + cfg = RUMConfig(MagicMock()) + client = AsyncMock() + client.get = AsyncMock(return_value={"data": _cfg(True)}) + + resources = _run(cfg.get_resources(client)) + + assert len(resources) == 1 + assert resources[0]["attributes"]["enforced_application_tags"] is True + client.get.assert_awaited_once_with("/api/v2/rum/config") + + +def test_import_resource_returns_default_id(): + cfg = RUMConfig(MagicMock()) + cfg.config.source_client = AsyncMock() + resource = _cfg(True) + _id, data = _run(cfg.import_resource(resource=resource)) + assert _id == "rum-config" + assert data is resource + + +def test_create_resource_posts_when_destination_absent(): + cfg = RUMConfig(MagicMock()) + dest = AsyncMock() + dest.get = AsyncMock(side_effect=Exception("404 not found")) + dest.post = AsyncMock(return_value={"data": _cfg(True)}) + cfg.config.destination_client = dest + + resource = _cfg(True) + _id, data = _run(cfg.create_resource("rum-config", resource)) + + assert _id == "rum-config" + dest.post.assert_awaited_once() + post_url, post_payload = dest.post.await_args.args + assert post_url == "/api/v2/rum/config" + # only enforced_application_tags is sent on create + assert post_payload == {"data": {"type": "rum_config", "attributes": {"enforced_application_tags": True}}} + + +def test_create_resource_delegates_to_update_when_destination_exists(): + cfg = RUMConfig(MagicMock()) + dest = AsyncMock() + dest.get = AsyncMock(return_value={"data": _cfg(False)}) + dest.patch = AsyncMock(return_value={"data": _cfg(True)}) + dest.post = AsyncMock() + cfg.config.destination_client = dest + cfg.config.state = MagicMock() + cfg.config.state.destination = defaultdict(dict) + + resource = _cfg(True) + _id, data = _run(cfg.create_resource("rum-config", resource)) + + assert _id == "rum-config" + dest.post.assert_not_awaited() + dest.patch.assert_awaited_once() + # state.destination hydrated with the existing dest config so update can proceed + assert cfg.config.state.destination["rum_config"]["rum-config"]["attributes"]["enforced_application_tags"] is False + + +def test_update_resource_patches_enforced_application_tags_only(): + cfg = RUMConfig(MagicMock()) + dest = AsyncMock() + dest.patch = AsyncMock(return_value={"data": _cfg(True)}) + cfg.config.destination_client = dest + + resource = _cfg(True) + _id, data = _run(cfg.update_resource("rum-config", resource)) + + assert _id == "rum-config" + dest.patch.assert_awaited_once() + patch_url, patch_payload = dest.patch.await_args.args + assert patch_url == "/api/v2/rum/config" + assert patch_payload == {"data": {"type": "rum_config", "attributes": {"enforced_application_tags": True}}} + + +def test_delete_resource_is_noop(): + cfg = RUMConfig(MagicMock()) + cfg.config.destination_client = AsyncMock() + cfg.config.logger = MagicMock() + _run(cfg.delete_resource("rum-config")) + cfg.config.destination_client.delete.assert_not_awaited() From 7d0b55ee22e60e0aee67a8242e2d95a86c7c39c4 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:51:18 -0400 Subject: [PATCH 2/3] fix(rum): only suppress 404 in rum_config _existing_destination Per review: _existing_destination() caught broad Exception and treated all failures as 'singleton absent'. On transient/network/auth errors, this incorrectly fell through to POST and produced misleading failures. Fix: catch CustomClientHTTPError and only suppress 404 (not found), re-raising everything else. A broad Exception fallback is kept for non-HTTP errors (e.g. JSON decode) but HTTP errors now propagate correctly. --- datadog_sync/model/rum_config.py | 6 ++++++ tests/unit/test_rum_config.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/datadog_sync/model/rum_config.py b/datadog_sync/model/rum_config.py index 03791440..ebfcb633 100644 --- a/datadog_sync/model/rum_config.py +++ b/datadog_sync/model/rum_config.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 @@ -62,6 +63,11 @@ async def _existing_destination(self) -> Optional[Dict]: try: resp = await destination_client.get(self.resource_config.base_path) return resp["data"] + except CustomClientHTTPError as e: + if e.status_code == 404: + self.config.logger.debug("rum_config: destination singleton not present (404)") + return None + raise except Exception as e: self.config.logger.debug(f"rum_config: destination singleton not present: {e}") return None diff --git a/tests/unit/test_rum_config.py b/tests/unit/test_rum_config.py index 40665fbb..68cea2b3 100644 --- a/tests/unit/test_rum_config.py +++ b/tests/unit/test_rum_config.py @@ -17,6 +17,8 @@ from collections import defaultdict from unittest.mock import AsyncMock, MagicMock +import pytest + from datadog_sync.model.rum_config import RUMConfig @@ -63,9 +65,15 @@ def test_import_resource_returns_default_id(): def test_create_resource_posts_when_destination_absent(): + """When the destination singleton returns 404, create POSTs a new one.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + cfg = RUMConfig(MagicMock()) dest = AsyncMock() - dest.get = AsyncMock(side_effect=Exception("404 not found")) + resp = MagicMock() + resp.status = 404 + resp.message = "Not Found" + dest.get = AsyncMock(side_effect=CustomClientHTTPError(resp, message="not found")) dest.post = AsyncMock(return_value={"data": _cfg(True)}) cfg.config.destination_client = dest @@ -80,6 +88,26 @@ def test_create_resource_posts_when_destination_absent(): assert post_payload == {"data": {"type": "rum_config", "attributes": {"enforced_application_tags": True}}} +def test_create_resource_reraises_on_non_404_error(): + """Transient/auth errors (500, 403, etc.) must propagate, not be swallowed + as 'singleton absent'.""" + from datadog_sync.utils.resource_utils import CustomClientHTTPError + + cfg = RUMConfig(MagicMock()) + dest = AsyncMock() + resp = MagicMock() + resp.status = 500 + resp.message = "Internal Server Error" + dest.get = AsyncMock(side_effect=CustomClientHTTPError(resp, message="server error")) + dest.post = AsyncMock() + cfg.config.destination_client = dest + + resource = _cfg(True) + with pytest.raises(CustomClientHTTPError): + _run(cfg.create_resource("rum-config", resource)) + dest.post.assert_not_awaited() + + def test_create_resource_delegates_to_update_when_destination_exists(): cfg = RUMConfig(MagicMock()) dest = AsyncMock() From 1cab88e6e8217c58731be07dfb8a99c1a2b2f13b Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 17:24:54 -0400 Subject: [PATCH 3/3] fix(rum): remove broad Exception fallback in rum_config Per review: the broad except Exception fallback in _existing_destination() converted timeouts, connection failures, and malformed responses into 'absent', causing create_resource to proceed with a POST after an inconclusive GET. Now only CustomClientHTTPError 404 is suppressed; all other exceptions propagate. --- datadog_sync/model/rum_config.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/datadog_sync/model/rum_config.py b/datadog_sync/model/rum_config.py index ebfcb633..ee5b1523 100644 --- a/datadog_sync/model/rum_config.py +++ b/datadog_sync/model/rum_config.py @@ -68,9 +68,6 @@ async def _existing_destination(self) -> Optional[Dict]: self.config.logger.debug("rum_config: destination singleton not present (404)") return None raise - except Exception as e: - self.config.logger.debug(f"rum_config: destination singleton not present: {e}") - return None async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: # If the destination singleton already exists, hydrate state and delegate