From 809a4c47d0194e4ca0659040f4c3b823ffe129e6 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:25:42 -0400 Subject: [PATCH 1/7] feat(rum): add rum_operation_strong_links resource RUM operation strong links are keyed by the composite (operation_id, feature_id). The create payload requires application_id and operation_name which are NOT in the response, so pre_resource_action_hook derives them from the parent source operation (it runs before connect_resources remaps operation_id). Only status is updatable, so update sends only status and the composite key is read from state.destination. application_id and operation_name are create-only, excluded from diffs via deep_diff_config (must survive prep_resource so create can read them). - datadog_sync/model/rum_operation_strong_links.py (new) - datadog_sync/models/__init__.py -- register - tests/unit/test_rum_operation_strong_links.py (new) -- 7 unit tests - README.md -- add rum_operation_strong_links (depends on rum_operations, rum_applications) Integration tests + VCR cassettes deferred (require sandbox-org API access). --- README.md | 2 + .../model/rum_operation_strong_links.py | 121 ++++++++++++++ datadog_sync/models/__init__.py | 1 + tests/unit/test_rum_operation_strong_links.py | 157 ++++++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 datadog_sync/model/rum_operation_strong_links.py create mode 100644 tests/unit/test_rum_operation_strong_links.py diff --git a/README.md b/README.md index 0751e884..db5878bd 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ When running againts multiple destination organizations, a seperate working dire | rum_applications | Sync Datadog RUM applications. | | rum_metrics | Sync Datadog RUM-based metrics. | | rum_operations | Sync Datadog RUM operations. | +| rum_operation_strong_links | Sync Datadog RUM operation strong links. | | rum_permanent_retention_filters | Sync Datadog permanent RUM retention filters (configure-only). | | rum_retention_filters | Sync Datadog RUM retention filters (generic + exclusion). | | rum_retention_filters_order | Sync Datadog RUM retention filters order. | @@ -368,6 +369,7 @@ See [Supported resources](#supported-resources) section below for potential reso | rum_applications | - | | rum_metrics | - | | rum_operations | rum_applications | +| rum_operation_strong_links | rum_operations, rum_applications | | rum_permanent_retention_filters | rum_applications | | rum_retention_filters | rum_applications | | rum_retention_filters_order | rum_applications, rum_retention_filters | diff --git a/datadog_sync/model/rum_operation_strong_links.py b/datadog_sync/model/rum_operation_strong_links.py new file mode 100644 index 00000000..fec87247 --- /dev/null +++ b/datadog_sync/model/rum_operation_strong_links.py @@ -0,0 +1,121 @@ +# 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 RUMOperationStrongLinks(BaseResource): + """RUM operation strong links. + + Strong links are keyed by the composite (operation_id, feature_id). The + create payload requires ``application_id`` and ``operation_name`` which are + NOT in the response, so ``pre_resource_action_hook`` derives them from the + parent source operation (it runs before ``connect_resources`` remaps + ``operation_id``). Only ``status`` is updatable, so update sends only + status and the composite key is read from state.destination. + + ``application_id`` and ``operation_name`` are create-only (not in the + response), so they are excluded from diffs via ``deep_diff_config`` rather + than ``excluded_attributes`` (they must survive ``prep_resource`` so create + can read them). + """ + + resource_type = "rum_operation_strong_links" + resource_config = ResourceConfig( + base_path="/api/v2/rum/operations/strong_links", + excluded_attributes=[ + "id", + "attributes.created_at", + "attributes.updated_at", + ], + resource_connections={ + "rum_operations": ["attributes.operation_id"], + "rum_applications": ["attributes.application_id"], + }, + deep_diff_config={ + "ignore_order": True, + # application_id and operation_name are create-only (not in the + # response), so a source-vs-destination diff would always flag them. + "exclude_regex_paths": [r".*\['application_id'\]", r".*\['operation_name'\]"], + }, + skip_resource_mapping=True, + ) + # Additional RUMOperationStrongLinks specific attributes + + 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]: + # No single-resource GET endpoint; the list endpoint is the only read. + # The normal import flow supplies a full resource from get_resources. + if not resource: + raise Exception( + f"rum_operation_strong_links import requires a resource body " + f"(no GET-by-id endpoint); got _id={_id!r}" + ) + + return resource["id"], resource + + async def pre_resource_action_hook(self, _id, resource: Dict) -> None: + # Derive application_id and operation_name from the parent source + # operation. Runs BEFORE connect_resources remaps operation_id, so the + # operation_id here is still the source id (state.source is keyed by it). + attrs = resource.setdefault("attributes", {}) + op_id = attrs.get("operation_id") + if not op_id: + return + source_op = self.config.state.source.get("rum_operations", {}).get(op_id) + if source_op: + src_attrs = source_op.get("attributes", {}) + if "application_id" in src_attrs: + attrs["application_id"] = src_attrs["application_id"] + if "name" in src_attrs: + attrs["operation_name"] = src_attrs["name"] + + 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 + # create data has no id (server-assigned) + resource.pop("id", None) + payload = {"data": resource} + 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 + dest_state = self.config.state.destination[self.resource_type][_id] + dest_attrs = dest_state["attributes"] + dest_op_id = dest_attrs["operation_id"] + feature_id = dest_attrs["feature_id"] + # only status is updatable + payload = { + "data": { + "type": self.resource_type, + "attributes": {"status": resource["attributes"].get("status")}, + } + } + resp = await destination_client.put( + f"{self.resource_config.base_path}/{dest_op_id}/{feature_id}", + payload, + ) + return _id, resp["data"] + + async def delete_resource(self, _id: str) -> None: + destination_client = self.config.destination_client + dest_state = self.config.state.destination[self.resource_type][_id] + dest_attrs = dest_state["attributes"] + dest_op_id = dest_attrs["operation_id"] + feature_id = dest_attrs["feature_id"] + await destination_client.delete(f"{self.resource_config.base_path}/{dest_op_id}/{feature_id}") diff --git a/datadog_sync/models/__init__.py b/datadog_sync/models/__init__.py index 46ff0476..e047df23 100644 --- a/datadog_sync/models/__init__.py +++ b/datadog_sync/models/__init__.py @@ -30,6 +30,7 @@ from datadog_sync.model.rum_applications import RUMApplications 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 from datadog_sync.model.rum_permanent_retention_filters import RUMPermanentRetentionFilters from datadog_sync.model.rum_retention_filters import RUMRetentionFilters from datadog_sync.model.rum_retention_filters_order import RUMRetentionFiltersOrder diff --git a/tests/unit/test_rum_operation_strong_links.py b/tests/unit/test_rum_operation_strong_links.py new file mode 100644 index 00000000..fd866229 --- /dev/null +++ b/tests/unit/test_rum_operation_strong_links.py @@ -0,0 +1,157 @@ +# 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 RUMOperationStrongLinks resource model. + +Strong links are keyed by the composite (operation_id, feature_id). The create +payload requires ``application_id`` and ``operation_name`` which are NOT in the +response, so ``pre_resource_action_hook`` derives them from the parent source +operation (it runs before ``connect_resources`` remaps ``operation_id``). Only +``status`` is updatable, so update sends only status and the composite key is +read from state.destination. +""" + +import asyncio +from collections import defaultdict +from unittest.mock import AsyncMock, MagicMock + +from datadog_sync.model.rum_operation_strong_links import RUMOperationStrongLinks + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _sl(_id, op_id="op-src", feature_id="feat-1", status="enabled"): + return { + "id": _id, + "type": "rum_operation_strong_links", + "attributes": { + "operation_id": op_id, + "feature_id": feature_id, + "status": status, + "description": "link desc", + "tags": ["env:prod"], + }, + } + + +def test_get_resources_hits_list_endpoint(): + sl = RUMOperationStrongLinks(MagicMock()) + client = AsyncMock() + client.get = AsyncMock(return_value={"data": [_sl("sl-1")], "meta": {}}) + + resources = _run(sl.get_resources(client)) + + assert resources == [_sl("sl-1")] + client.get.assert_awaited_once_with("/api/v2/rum/operations/strong_links") + + +def test_import_resource_by_id_passthrough(): + sl = RUMOperationStrongLinks(MagicMock()) + sl.config.source_client = AsyncMock() + resource = _sl("sl-1") + _id, data = _run(sl.import_resource(resource=resource)) + assert _id == "sl-1" + assert data is resource + + +def test_pre_resource_action_hook_derives_application_id_and_operation_name(): + sl = RUMOperationStrongLinks(MagicMock()) + sl.config.state = MagicMock() + sl.config.state.source = defaultdict(dict) + sl.config.state.source["rum_operations"] = { + "op-src": { + "id": "op-src", + "attributes": {"application_id": "app-src", "name": "checkout-flow"}, + } + } + + resource = _sl("sl-1", op_id="op-src") + _run(sl.pre_resource_action_hook("sl-1", resource)) + + assert resource["attributes"]["application_id"] == "app-src" + assert resource["attributes"]["operation_name"] == "checkout-flow" + + +def test_create_resource_posts_with_derived_fields(): + sl = RUMOperationStrongLinks(MagicMock()) + dest = AsyncMock() + dest.post = AsyncMock(return_value={"data": _sl("sl-dst", op_id="op-dst")}) + sl.config.destination_client = dest + + resource = _sl("sl-1", op_id="op-dst") + resource["attributes"]["application_id"] = "app-dst" + resource["attributes"]["operation_name"] = "checkout-flow" + _id, data = _run(sl.create_resource("sl-1", resource)) + + assert _id == "sl-1" + assert data["id"] == "sl-dst" + 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/operations/strong_links" + assert post_payload == {"data": resource} + + +def test_update_resource_puts_composite_key_and_status_only(): + sl = RUMOperationStrongLinks(MagicMock()) + dest = AsyncMock() + dest.put = AsyncMock(return_value={"data": _sl("sl-dst", op_id="op-dst", status="disabled")}) + sl.config.destination_client = dest + sl.config.state = MagicMock() + sl.config.state.destination = defaultdict(dict) + sl.config.state.destination["rum_operation_strong_links"]["sl-1"] = { + "id": "sl-dst", + "attributes": {"operation_id": "op-dst", "feature_id": "feat-1", "status": "enabled"}, + } + + resource = _sl("sl-1", op_id="op-dst", status="disabled") + _id, data = _run(sl.update_resource("sl-1", resource)) + + assert _id == "sl-1" + dest.put.assert_awaited_once() + put_url, put_payload = dest.put.await_args.args + assert put_url == "/api/v2/rum/operations/strong_links/op-dst/feat-1" + # update sends only the updatable field (status) + assert put_payload == {"data": {"type": "rum_operation_strong_links", "attributes": {"status": "disabled"}}} + + +def test_delete_resource_deletes_composite_key(): + sl = RUMOperationStrongLinks(MagicMock()) + dest = AsyncMock() + sl.config.destination_client = dest + sl.config.state = MagicMock() + sl.config.state.destination = defaultdict(dict) + sl.config.state.destination["rum_operation_strong_links"]["sl-1"] = { + "id": "sl-dst", + "attributes": {"operation_id": "op-dst", "feature_id": "feat-1"}, + } + + _run(sl.delete_resource("sl-1")) + + dest.delete.assert_awaited_once_with("/api/v2/rum/operations/strong_links/op-dst/feat-1") + + +def test_connect_resources_remaps_operation_id(): + sl = RUMOperationStrongLinks(MagicMock()) + sl.config.state = MagicMock() + sl.config.state.destination = defaultdict(dict) + sl.config.state.destination["rum_operations"]["op-src"] = {"id": "op-dst"} + sl.config.state.destination["rum_applications"]["app-src"] = {"id": "app-dst"} + sl.config.skip_failed_resource_connections = False + sl.config.logger = MagicMock() + + resource = _sl("sl-1", op_id="op-src") + resource["attributes"]["application_id"] = "app-src" + sl.connect_resources("sl-1", resource) + + assert resource["attributes"]["operation_id"] == "op-dst" + assert resource["attributes"]["application_id"] == "app-dst" From 22162627383465073ca08eeed3164b15b1c46a52 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Fri, 25 Sep 2026 16:51:18 -0400 Subject: [PATCH 2/7] fix(rum): raise SkipResource when parent operation missing Per review: pre_resource_action_hook silently no-ops when rum_operations source state is missing, which can lead to create payloads lacking application_id/operation_name and producing less-actionable API errors later. Now raises SkipResource with an explicit message when the parent operation can't be found or operation_id is missing, so operators get an immediate, actionable error. --- .../model/rum_operation_strong_links.py | 26 +++++++++++---- tests/unit/test_rum_operation_strong_links.py | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/datadog_sync/model/rum_operation_strong_links.py b/datadog_sync/model/rum_operation_strong_links.py index fec87247..c847c5b6 100644 --- a/datadog_sync/model/rum_operation_strong_links.py +++ b/datadog_sync/model/rum_operation_strong_links.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 SkipResource if TYPE_CHECKING: from datadog_sync.utils.custom_client import CustomClient @@ -73,14 +74,25 @@ async def pre_resource_action_hook(self, _id, resource: Dict) -> None: attrs = resource.setdefault("attributes", {}) op_id = attrs.get("operation_id") if not op_id: - return + raise SkipResource( + _id, + self.resource_type, + f"Missing operation_id; cannot derive application_id and " f"operation_name for create payload.", + ) source_op = self.config.state.source.get("rum_operations", {}).get(op_id) - if source_op: - src_attrs = source_op.get("attributes", {}) - if "application_id" in src_attrs: - attrs["application_id"] = src_attrs["application_id"] - if "name" in src_attrs: - attrs["operation_name"] = src_attrs["name"] + if not source_op: + raise SkipResource( + _id, + self.resource_type, + f"Parent rum_operations {op_id!r} not found in source state; " + f"cannot derive application_id and operation_name for create payload. " + f"Ensure rum_operations is imported and synced before this resource.", + ) + src_attrs = source_op.get("attributes", {}) + if "application_id" in src_attrs: + attrs["application_id"] = src_attrs["application_id"] + if "name" in src_attrs: + attrs["operation_name"] = src_attrs["name"] async def pre_apply_hook(self) -> None: pass diff --git a/tests/unit/test_rum_operation_strong_links.py b/tests/unit/test_rum_operation_strong_links.py index fd866229..2daad2ef 100644 --- a/tests/unit/test_rum_operation_strong_links.py +++ b/tests/unit/test_rum_operation_strong_links.py @@ -18,6 +18,8 @@ from collections import defaultdict from unittest.mock import AsyncMock, MagicMock +import pytest + from datadog_sync.model.rum_operation_strong_links import RUMOperationStrongLinks @@ -81,6 +83,36 @@ def test_pre_resource_action_hook_derives_application_id_and_operation_name(): assert resource["attributes"]["operation_name"] == "checkout-flow" +def test_pre_resource_action_hook_raises_skip_when_operation_not_found(): + """When the parent operation is missing from source state, raise + SkipResource so the operator gets an explicit error instead of a + silent no-op that produces an incomplete create payload.""" + from datadog_sync.utils.resource_utils import SkipResource + + sl = RUMOperationStrongLinks(MagicMock()) + sl.config.state = MagicMock() + sl.config.state.source = defaultdict(dict) + sl.config.state.source["rum_operations"] = {} + + resource = _sl("sl-1", op_id="op-missing") + with pytest.raises(SkipResource): + _run(sl.pre_resource_action_hook("sl-1", resource)) + + +def test_pre_resource_action_hook_raises_skip_when_operation_id_missing(): + """When the resource has no operation_id at all, raise SkipResource.""" + from datadog_sync.utils.resource_utils import SkipResource + + sl = RUMOperationStrongLinks(MagicMock()) + sl.config.state = MagicMock() + sl.config.state.source = defaultdict(dict) + + resource = _sl("sl-1", op_id="op-src") + resource["attributes"].pop("operation_id") + with pytest.raises(SkipResource): + _run(sl.pre_resource_action_hook("sl-1", resource)) + + def test_create_resource_posts_with_derived_fields(): sl = RUMOperationStrongLinks(MagicMock()) dest = AsyncMock() From 6e178a96265d7d2b8a08821496d444ff0f1a437b Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Mon, 28 Sep 2026 10:08:27 -0400 Subject: [PATCH 3/7] fix(rum): paginate strong_links list + exclude non-updatable fields from diff Per review (two comments): 1. The list endpoint is paginated but get_resources only returned the first page. Switch to client.paginated_request with PaginationConfig so all strong links are imported. Test updated to verify paginated_request is used. 2. The diff included response attributes that update_resource cannot change (description, tags, feature_id, operation_id). If any differed while status was equal, the handler repeatedly scheduled an update but the PUT sends only status, creating a non-converging loop. Now all non-updatable response fields are excluded from the diff via deep_diff_config (keeping them in the resource for create, but excluding from comparison). --- .../model/rum_operation_strong_links.py | 27 ++++++++++++++++--- tests/unit/test_rum_operation_strong_links.py | 10 +++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/datadog_sync/model/rum_operation_strong_links.py b/datadog_sync/model/rum_operation_strong_links.py index c847c5b6..c2c450bd 100644 --- a/datadog_sync/model/rum_operation_strong_links.py +++ b/datadog_sync/model/rum_operation_strong_links.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.custom_client import PaginationConfig from datadog_sync.utils.resource_utils import SkipResource if TYPE_CHECKING: @@ -45,16 +46,36 @@ class RUMOperationStrongLinks(BaseResource): "ignore_order": True, # application_id and operation_name are create-only (not in the # response), so a source-vs-destination diff would always flag them. - "exclude_regex_paths": [r".*\['application_id'\]", r".*\['operation_name'\]"], + # description, tags, feature_id, operation_id are in the response + # but NOT updatable via PUT (only status is), so including them in + # the diff would cause a non-converging update loop. + "exclude_regex_paths": [ + r".*\['application_id'\]", + r".*\['operation_name'\]", + r".*\['description'\]", + r".*\['tags'\]", + r".*\['feature_id'\]", + r".*\['operation_id'\]", + ], }, skip_resource_mapping=True, ) # Additional RUMOperationStrongLinks specific attributes + pagination_config = PaginationConfig( + page_size=100, + page_size_param="page[limit]", + page_number_param="page[offset]", + page_number_func=lambda idx, page_size, page_number: page_number + page_size, + remaining_func=lambda *args: 1, + ) async def get_resources(self, client: CustomClient) -> List[Dict]: - resp = await client.get(self.resource_config.base_path) + resp = await client.paginated_request(client.get)( + self.resource_config.base_path, + pagination_config=self.pagination_config, + ) - return resp["data"] + return resp async def import_resource(self, _id: Optional[str] = None, resource: Optional[Dict] = None) -> Tuple[str, Dict]: # No single-resource GET endpoint; the list endpoint is the only read. diff --git a/tests/unit/test_rum_operation_strong_links.py b/tests/unit/test_rum_operation_strong_links.py index 2daad2ef..b7cdd518 100644 --- a/tests/unit/test_rum_operation_strong_links.py +++ b/tests/unit/test_rum_operation_strong_links.py @@ -48,12 +48,18 @@ def _sl(_id, op_id="op-src", feature_id="feat-1", status="enabled"): def test_get_resources_hits_list_endpoint(): sl = RUMOperationStrongLinks(MagicMock()) client = AsyncMock() - client.get = AsyncMock(return_value={"data": [_sl("sl-1")], "meta": {}}) + # paginated_request(func) returns a wrapper coroutine; mock the wrapper + wrapper_mock = AsyncMock(return_value=[_sl("sl-1")]) + client.paginated_request = MagicMock(return_value=wrapper_mock) resources = _run(sl.get_resources(client)) assert resources == [_sl("sl-1")] - client.get.assert_awaited_once_with("/api/v2/rum/operations/strong_links") + client.paginated_request.assert_called_once_with(client.get) + wrapper_mock.assert_called_once_with( + "/api/v2/rum/operations/strong_links", + pagination_config=sl.pagination_config, + ) def test_import_resource_by_id_passthrough(): From a053f98d03d98a767fc7c5523ec30120ce05dadd Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 12:13:04 -0400 Subject: [PATCH 4/7] fix(rum): iterate operations for strong_links list endpoint Per integration test findings (EU1 -> US5 sync): The strong_links list endpoint (GET /api/v2/rum/operations/strong_links) requires at least one of operation_id or feature_id as a query parameter. Without it, the API returns 400 'At least one of operation_id or feature_id must be provided'. The OpenAPI spec marks them optional, but the API enforces at least one. Fix: get_resources now iterates over rum_operations in state (source for import, destination for apply) and fetches strong links per operation_id using a query parameter. Falls back to an empty list when no operations are in state yet. Removed the unused PaginationConfig since per-operation fetches are unlikely to need pagination. Two new tests: one verifying per-operation iteration, one verifying empty result when no operations exist. --- .../model/rum_operation_strong_links.py | 39 ++++++++++------ tests/unit/test_rum_operation_strong_links.py | 46 +++++++++++++++---- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/datadog_sync/model/rum_operation_strong_links.py b/datadog_sync/model/rum_operation_strong_links.py index c2c450bd..8d0533ef 100644 --- a/datadog_sync/model/rum_operation_strong_links.py +++ b/datadog_sync/model/rum_operation_strong_links.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Optional, List, Dict, Tuple from datadog_sync.utils.base_resource import BaseResource, ResourceConfig -from datadog_sync.utils.custom_client import PaginationConfig from datadog_sync.utils.resource_utils import SkipResource if TYPE_CHECKING: @@ -61,21 +60,33 @@ class RUMOperationStrongLinks(BaseResource): skip_resource_mapping=True, ) # Additional RUMOperationStrongLinks specific attributes - pagination_config = PaginationConfig( - page_size=100, - page_size_param="page[limit]", - page_number_param="page[offset]", - page_number_func=lambda idx, page_size, page_number: page_number + page_size, - remaining_func=lambda *args: 1, - ) async def get_resources(self, client: CustomClient) -> List[Dict]: - resp = await client.paginated_request(client.get)( - self.resource_config.base_path, - pagination_config=self.pagination_config, - ) - - return resp + # The strong_links list endpoint requires at least one of operation_id + # or feature_id as a query parameter (the OpenAPI spec marks them + # optional, but the API returns 400 without one). Iterate over the + # rum_operations in state and fetch strong links per operation_id. + # + # For the source client (import), state.source["rum_operations"] is + # populated by the dependency graph (rum_operations is imported first). + # For the destination client (apply), state.destination["rum_operations"] + # is populated after rum_operations is synced. + # + # Fall back to an empty list if no operations are in state yet (e.g. + # first import before rum_operations has been loaded). + is_source = client is self.config.source_client + state_key = "source" if is_source else "destination" + state_map = getattr(self.config.state, state_key, {}) + operations = state_map.get("rum_operations", {}) if hasattr(state_map, "get") else {} + + all_strong_links: List[Dict] = [] + for op_id in operations: + resp = await client.get( + self.resource_config.base_path, + params={"operation_id": op_id}, + ) + all_strong_links.extend(resp.get("data", [])) + return all_strong_links async def import_resource(self, _id: Optional[str] = None, resource: Optional[Dict] = None) -> Tuple[str, Dict]: # No single-resource GET endpoint; the list endpoint is the only read. diff --git a/tests/unit/test_rum_operation_strong_links.py b/tests/unit/test_rum_operation_strong_links.py index b7cdd518..619b9dcf 100644 --- a/tests/unit/test_rum_operation_strong_links.py +++ b/tests/unit/test_rum_operation_strong_links.py @@ -45,21 +45,47 @@ def _sl(_id, op_id="op-src", feature_id="feat-1", status="enabled"): } -def test_get_resources_hits_list_endpoint(): +def test_get_resources_iterates_operations_from_state(): + """get_resources iterates over rum_operations in state and fetches strong + links per operation_id (the list endpoint requires operation_id).""" sl = RUMOperationStrongLinks(MagicMock()) client = AsyncMock() - # paginated_request(func) returns a wrapper coroutine; mock the wrapper - wrapper_mock = AsyncMock(return_value=[_sl("sl-1")]) - client.paginated_request = MagicMock(return_value=wrapper_mock) + # Two operations in state, each returning one strong link + client.get = AsyncMock( + side_effect=[ + {"data": [_sl("sl-1", op_id="op-a")]}, + {"data": [_sl("sl-2", op_id="op-b")]}, + ] + ) + # Mock state to have rum_operations nested under source + sl.config.source_client = client + sl.config.state = MagicMock() + sl.config.state.source = {"rum_operations": {"op-a": {"id": "op-a"}, "op-b": {"id": "op-b"}}} + sl.config.state.destination = {"rum_operations": {}} resources = _run(sl.get_resources(client)) - assert resources == [_sl("sl-1")] - client.paginated_request.assert_called_once_with(client.get) - wrapper_mock.assert_called_once_with( - "/api/v2/rum/operations/strong_links", - pagination_config=sl.pagination_config, - ) + assert len(resources) == 2 + assert resources[0]["id"] == "sl-1" + assert resources[1]["id"] == "sl-2" + # Each call should include operation_id as a query param + assert client.get.await_count == 2 + first_call_kwargs = client.get.await_args_list[0].kwargs + assert first_call_kwargs["params"] == {"operation_id": "op-a"} + + +def test_get_resources_returns_empty_when_no_operations(): + """When no rum_operations are in state, get_resources returns an empty list.""" + sl = RUMOperationStrongLinks(MagicMock()) + client = AsyncMock() + sl.config.source_client = client + sl.config.state = MagicMock() + sl.config.state.source = {"rum_operations": {}} + sl.config.state.destination = {"rum_operations": {}} + + resources = _run(sl.get_resources(client)) + assert resources == [] + client.get.assert_not_awaited() def test_import_resource_by_id_passthrough(): From 456bc1bbec6aecde51a765e26e7740608204bdb2 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 14:13:08 -0400 Subject: [PATCH 5/7] fix(rum): fetch operations from API during strong_links import Per integration test re-test: get_resources used state.source['rum_operations'] to find operation IDs, but during import discovery all resource types run in parallel, so state isn't populated yet -> 0 strong links imported. Fix: for the source client (import), get_resources now fetches operations directly from the API search endpoint instead of relying on state. For the destination client (apply), it still uses state (rum_operations is already synced by then). Tests updated to verify both paths. --- .../model/rum_operation_strong_links.py | 30 ++++++---- tests/unit/test_rum_operation_strong_links.py | 55 ++++++++++++++----- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/datadog_sync/model/rum_operation_strong_links.py b/datadog_sync/model/rum_operation_strong_links.py index 8d0533ef..ebcd9ec1 100644 --- a/datadog_sync/model/rum_operation_strong_links.py +++ b/datadog_sync/model/rum_operation_strong_links.py @@ -64,23 +64,31 @@ class RUMOperationStrongLinks(BaseResource): async def get_resources(self, client: CustomClient) -> List[Dict]: # The strong_links list endpoint requires at least one of operation_id # or feature_id as a query parameter (the OpenAPI spec marks them - # optional, but the API returns 400 without one). Iterate over the - # rum_operations in state and fetch strong links per operation_id. + # optional, but the API returns 400 without one). Iterate over + # rum_operations and fetch strong links per operation_id. # # For the source client (import), state.source["rum_operations"] is - # populated by the dependency graph (rum_operations is imported first). - # For the destination client (apply), state.destination["rum_operations"] - # is populated after rum_operations is synced. + # typically NOT populated yet because all resource types are discovered + # in parallel. So we fetch operations directly from the API via the + # search endpoint instead of relying on state. # - # Fall back to an empty list if no operations are in state yet (e.g. - # first import before rum_operations has been loaded). + # For the destination client (apply), state.destination["rum_operations"] + # is populated after rum_operations is synced (apply runs after import). is_source = client is self.config.source_client - state_key = "source" if is_source else "destination" - state_map = getattr(self.config.state, state_key, {}) - operations = state_map.get("rum_operations", {}) if hasattr(state_map, "get") else {} + + if is_source: + # Import discovery: fetch operations from the API since state + # isn't populated yet (all types discover in parallel). + ops_resp = await client.get("/api/v2/rum/operations/search") + operation_ids = [op["id"] for op in ops_resp.get("data", [])] + else: + # Destination apply: use state (rum_operations already synced). + state_map = getattr(self.config.state, "destination", {}) + operations = state_map.get("rum_operations", {}) if hasattr(state_map, "get") else {} + operation_ids = list(operations.keys()) all_strong_links: List[Dict] = [] - for op_id in operations: + for op_id in operation_ids: resp = await client.get( self.resource_config.base_path, params={"operation_id": op_id}, diff --git a/tests/unit/test_rum_operation_strong_links.py b/tests/unit/test_rum_operation_strong_links.py index 619b9dcf..1afa8942 100644 --- a/tests/unit/test_rum_operation_strong_links.py +++ b/tests/unit/test_rum_operation_strong_links.py @@ -45,22 +45,22 @@ def _sl(_id, op_id="op-src", feature_id="feat-1", status="enabled"): } -def test_get_resources_iterates_operations_from_state(): - """get_resources iterates over rum_operations in state and fetches strong - links per operation_id (the list endpoint requires operation_id).""" +def test_get_resources_fetches_operations_from_api_for_source(): + """For the source client (import), get_resources fetches operations from + the API search endpoint (state isn't populated during discovery).""" sl = RUMOperationStrongLinks(MagicMock()) client = AsyncMock() - # Two operations in state, each returning one strong link + # First GET: search endpoint returns 2 operations + # Next 2 GETs: strong_links per operation_id client.get = AsyncMock( side_effect=[ - {"data": [_sl("sl-1", op_id="op-a")]}, - {"data": [_sl("sl-2", op_id="op-b")]}, + {"data": [{"id": "op-a"}, {"id": "op-b"}]}, # search + {"data": [_sl("sl-1", op_id="op-a")]}, # strong links for op-a + {"data": [_sl("sl-2", op_id="op-b")]}, # strong links for op-b ] ) - # Mock state to have rum_operations nested under source sl.config.source_client = client sl.config.state = MagicMock() - sl.config.state.source = {"rum_operations": {"op-a": {"id": "op-a"}, "op-b": {"id": "op-b"}}} sl.config.state.destination = {"rum_operations": {}} resources = _run(sl.get_resources(client)) @@ -68,24 +68,49 @@ def test_get_resources_iterates_operations_from_state(): assert len(resources) == 2 assert resources[0]["id"] == "sl-1" assert resources[1]["id"] == "sl-2" - # Each call should include operation_id as a query param - assert client.get.await_count == 2 - first_call_kwargs = client.get.await_args_list[0].kwargs - assert first_call_kwargs["params"] == {"operation_id": "op-a"} + # First call is the search endpoint + assert client.get.await_args_list[0].args[0] == "/api/v2/rum/operations/search" + # Subsequent calls include operation_id as a query param + assert client.get.await_args_list[1].kwargs["params"] == {"operation_id": "op-a"} + assert client.get.await_args_list[2].kwargs["params"] == {"operation_id": "op-b"} + + +def test_get_resources_uses_state_for_destination(): + """For the destination client (apply), get_resources uses state to get + operation IDs (rum_operations already synced).""" + sl = RUMOperationStrongLinks(MagicMock()) + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + {"data": [_sl("sl-1", op_id="op-dst")]}, + ] + ) + # Not the source client -> destination path + sl.config.source_client = AsyncMock() # different object + sl.config.state = MagicMock() + sl.config.state.destination = {"rum_operations": {"op-dst": {"id": "op-dst"}}} + + resources = _run(sl.get_resources(client)) + + assert len(resources) == 1 + assert resources[0]["id"] == "sl-1" + # No search endpoint call; direct strong_links fetch with operation_id + assert client.get.await_count == 1 + assert client.get.await_args_list[0].kwargs["params"] == {"operation_id": "op-dst"} def test_get_resources_returns_empty_when_no_operations(): - """When no rum_operations are in state, get_resources returns an empty list.""" + """When no operations are found, get_resources returns an empty list.""" sl = RUMOperationStrongLinks(MagicMock()) client = AsyncMock() + # Source client: search returns no operations + client.get = AsyncMock(return_value={"data": []}) sl.config.source_client = client sl.config.state = MagicMock() - sl.config.state.source = {"rum_operations": {}} sl.config.state.destination = {"rum_operations": {}} resources = _run(sl.get_resources(client)) assert resources == [] - client.get.assert_not_awaited() def test_import_resource_by_id_passthrough(): From 9024c4b43fbc6d4730e703dd30fdec9cc996deb8 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 15:42:24 -0400 Subject: [PATCH 6/7] fix(rum): add destination reconciliation to strong_links create Per fuzz test findings: rum_operation_strong_links has skip_resource_mapping=True, so the pre-apply listing phase is skipped. When strong links already exist at the destination from a previous sync, create_resource POSTs unconditionally and gets 409 Conflict. Fix: create_resource now searches for an existing strong link with the same operation_id + feature_id at the destination before POSTing. If found, it hydrates state and delegates to update. New test verifies the reconciliation path. --- .../model/rum_operation_strong_links.py | 26 ++++++++++++++++++- tests/unit/test_rum_operation_strong_links.py | 25 ++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/datadog_sync/model/rum_operation_strong_links.py b/datadog_sync/model/rum_operation_strong_links.py index ebcd9ec1..605c9060 100644 --- a/datadog_sync/model/rum_operation_strong_links.py +++ b/datadog_sync/model/rum_operation_strong_links.py @@ -7,7 +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 SkipResource +from datadog_sync.utils.resource_utils import CustomClientHTTPError, SkipResource if TYPE_CHECKING: from datadog_sync.utils.custom_client import CustomClient @@ -141,6 +141,30 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client # create data has no id (server-assigned) resource.pop("id", None) + + # Destination reconciliation: skip_resource_mapping=True means the + # pre-apply listing phase is skipped. Before POSTing, search for an + # existing strong link with the same operation_id + feature_id at the + # destination and adopt it via update instead of creating a duplicate + # (409 Conflict). + attrs = resource.get("attributes", {}) + op_id = attrs.get("operation_id", "") + feature_id = attrs.get("feature_id", "") + if op_id: + try: + existing = await destination_client.get( + self.resource_config.base_path, + params={"operation_id": op_id}, + ) + for sl in existing.get("data", []): + sl_attrs = sl.get("attributes", {}) + if sl_attrs.get("operation_id") == op_id and sl_attrs.get("feature_id") == feature_id: + self.config.state.destination[self.resource_type][_id] = sl + 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(self.resource_config.base_path, payload) return _id, resp["data"] diff --git a/tests/unit/test_rum_operation_strong_links.py b/tests/unit/test_rum_operation_strong_links.py index 1afa8942..44f4a6e8 100644 --- a/tests/unit/test_rum_operation_strong_links.py +++ b/tests/unit/test_rum_operation_strong_links.py @@ -173,6 +173,8 @@ def test_pre_resource_action_hook_raises_skip_when_operation_id_missing(): def test_create_resource_posts_with_derived_fields(): sl = RUMOperationStrongLinks(MagicMock()) dest = AsyncMock() + # Reconciliation GET returns no matching strong link + dest.get = AsyncMock(return_value={"data": []}) dest.post = AsyncMock(return_value={"data": _sl("sl-dst", op_id="op-dst")}) sl.config.destination_client = dest @@ -190,6 +192,29 @@ def test_create_resource_posts_with_derived_fields(): assert post_payload == {"data": resource} +def test_create_resource_reconciles_existing_destination(): + """When a matching strong link already exists at the destination (same + operation_id + feature_id), create_resource hydrates state and delegates + to update instead of creating a duplicate (409 Conflict).""" + sl = RUMOperationStrongLinks(MagicMock()) + dest = AsyncMock() + existing = _sl("sl-existing", op_id="op-dst", feature_id="feat-1") + dest.get = AsyncMock(return_value={"data": [existing]}) + dest.put = AsyncMock(return_value={"data": existing}) + dest.post = AsyncMock() + sl.config.destination_client = dest + sl.config.state = MagicMock() + sl.config.state.destination = defaultdict(dict) + + resource = _sl("sl-1", op_id="op-dst", feature_id="feat-1") + _id, data = _run(sl.create_resource("sl-1", resource)) + + assert _id == "sl-1" + dest.post.assert_not_awaited() + dest.put.assert_awaited_once() + assert sl.config.state.destination["rum_operation_strong_links"]["sl-1"]["id"] == "sl-existing" + + def test_update_resource_puts_composite_key_and_status_only(): sl = RUMOperationStrongLinks(MagicMock()) dest = AsyncMock() From 258282dea0710bfd9a7ecd4c5efe3e181de20541 Mon Sep 17 00:00:00 2001 From: Michael Richey Date: Thu, 1 Oct 2026 16:17:02 -0400 Subject: [PATCH 7/7] fix(rum): use API type 'strong_links' not sync-cli resource_type in payloads Per integration test re-test: create_resource sent the resource body as-is with type='rum_operation_strong_links' (the sync-cli resource type), but the API expects type='strong_links'. This caused 400 Bad Request on 50 of 80 strong links. update_resource had the same bug in its PUT payload. Fix: create_resource now sets resource['type'] = 'strong_links' before POSTing. update_resource uses 'strong_links' in the PUT payload type field instead of self.resource_type. Test updated to verify the correct API type. --- datadog_sync/model/rum_operation_strong_links.py | 5 ++++- tests/unit/test_rum_operation_strong_links.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/datadog_sync/model/rum_operation_strong_links.py b/datadog_sync/model/rum_operation_strong_links.py index 605c9060..b5231a10 100644 --- a/datadog_sync/model/rum_operation_strong_links.py +++ b/datadog_sync/model/rum_operation_strong_links.py @@ -166,6 +166,9 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: raise payload = {"data": resource} + # Ensure the API type is correct: the API expects "strong_links", + # not the sync-cli resource_type "rum_operation_strong_links". + resource["type"] = "strong_links" resp = await destination_client.post(self.resource_config.base_path, payload) return _id, resp["data"] @@ -178,7 +181,7 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: # only status is updatable payload = { "data": { - "type": self.resource_type, + "type": "strong_links", "attributes": {"status": resource["attributes"].get("status")}, } } diff --git a/tests/unit/test_rum_operation_strong_links.py b/tests/unit/test_rum_operation_strong_links.py index 44f4a6e8..1c36b007 100644 --- a/tests/unit/test_rum_operation_strong_links.py +++ b/tests/unit/test_rum_operation_strong_links.py @@ -190,6 +190,8 @@ def test_create_resource_posts_with_derived_fields(): post_url, post_payload = dest.post.await_args.args assert post_url == "/api/v2/rum/operations/strong_links" assert post_payload == {"data": resource} + # The API type must be "strong_links", not the sync-cli resource_type + assert resource["type"] == "strong_links" def test_create_resource_reconciles_existing_destination(): @@ -235,7 +237,7 @@ def test_update_resource_puts_composite_key_and_status_only(): put_url, put_payload = dest.put.await_args.args assert put_url == "/api/v2/rum/operations/strong_links/op-dst/feat-1" # update sends only the updatable field (status) - assert put_payload == {"data": {"type": "rum_operation_strong_links", "attributes": {"status": "disabled"}}} + assert put_payload == {"data": {"type": "strong_links", "attributes": {"status": "disabled"}}} def test_delete_resource_deletes_composite_key():