Skip to content

feat(rum): add rum_operations resource - #719

Open
michael-richey wants to merge 4 commits into
michael.richey/add-rum-permanent-retention-filtersfrom
michael.richey/add-rum-operations
Open

michael-richey wants to merge 4 commits into
michael.richey/add-rum-permanent-retention-filtersfrom
michael.richey/add-rum-operations

Conversation

@michael-richey

Copy link
Copy Markdown
Collaborator

Summary

RUM operations — static definitions (name, display_name, category, query, journey rules) tied to a RUM application via attributes.application_id (a real body field, remapped to the destination app id via resource_connections).

Stacked on #718 (rum_permanent_retention_filters).

Feasibility spike result

The plan flagged a concern that operations might embed session-id references that can't be cleanly stripped. Inspection of the RUMOperation schema confirms the opposite: the query fields in journey_rum.rum_steps are RUM query filters (e.g. @type:view), not session ids. Operations are clean static definitions — no session-id stripping required.

Design

  • List via /rum/operations/search GET; create POST; update PUT /{id}; delete DELETE /{id}.
  • Create data has no id (server-assigned, popped); update sets resource["id"] = destination_id.
  • resource_connections={"rum_applications": ["attributes.application_id"]} — application_id is a real body field, remapped via connect_id (no synthetic field needed).
  • excluded_attributes: id + server-managed created_at/created_by/updated_at/updated_by/org_id.

Changes

  • datadog_sync/model/rum_operations.py (new)
  • datadog_sync/models/__init__.py — register
  • tests/unit/test_rum_operations.py (new) — 7 unit tests
  • README.md — add rum_operations (depends on rum_applications)

Testing

  • pytest tests/unit/test_rum_operations.py → 7 passed
  • Full unit suite: no new failures.

Follow-up

Integration tests + VCR cassettes deferred (require sandbox-org API access).

@michael-richey michael-richey left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition.

Implementation and tests are consistent with existing model conventions:

  • search endpoint read path is clear
  • create/update/delete URL + payload behavior is covered
  • application remapping via resource_connections is tested

No new blocking issues found in this PR diff.

@michael-richey
michael-richey force-pushed the michael.richey/add-rum-permanent-retention-filters branch from d79679a to 7561097 Compare September 25, 2026 20:53
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-operations branch from 2e1c183 to 6dd8547 Compare September 25, 2026 20:53
@michael-richey
michael-richey requested a balanced review from Copilot September 25, 2026 21:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The search endpoint only imports its first page, potentially omitting operations.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds synchronization support for Datadog RUM operation definitions and their application dependencies.

Changes:

  • Implements CRUD and application-ID remapping for RUM operations.
  • Registers and documents the resource.
  • Adds unit coverage for core synchronization behavior.
File Description
datadog_sync/​model/​rum_operations.py Implements the RUM operations resource model.
datadog_sync/​models/​__init__.py Registers the model.
tests/​unit/​test_rum_operations.py Tests CRUD and ID remapping.
README.md Documents support and dependencies.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread datadog_sync/model/rum_operations.py Outdated
Comment on lines +47 to +49
resp = await client.get(self._search_path)

return resp["data"]
@michael-richey

Copy link
Copy Markdown
Collaborator Author

Thanks for catching this — fixed in c2793024. get_resources now uses client.paginated_request with 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) so all pages are fetched, not just the first. Test updated to verify paginated_request is used with the correct path and pagination config.

@michael-richey
michael-richey force-pushed the michael.richey/add-rum-permanent-retention-filters branch from 7561097 to 9192e66 Compare September 28, 2026 14:10
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-operations branch from c279302 to 608bc96 Compare September 28, 2026 14:10
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-permanent-retention-filters branch from 9192e66 to 864e6dd Compare October 1, 2026 15:54
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-operations branch from 608bc96 to f73ec4c Compare October 1, 2026 15:54
@michael-richey

Copy link
Copy Markdown
Collaborator Author

Integration Test Finding: rum_operations does not reconcile existing destination resources

During a full integration test (EU1 → US5 sync), rum_operations failed with a 409 Conflict on the second run:

409 Conflict - {"errors":[{"status":"409","detail":"Operation with this name already exists"}]}

What happened

The sync tried to CREATE the operation (POST) instead of updating or skipping it, even though the operation already existed in the destination org from a previous sync run.

Root cause

rum_operations has skip_resource_mapping=True, which causes the sync-cli to skip the destination pre-listing phase (map_existing_resources). This means state.destination["rum_operations"] is never populated, so the apply logic always takes the create path:

# resources_handler.py line 570
if _id in self.config.state.destination[resource_type]:
    # update path (never reached)
else:
    # create path (always runs)
    await r_class._create_resource(_id, resource)

The create_resource method does a plain POST without checking if a matching operation already exists at the destination:

async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
    destination_client = self.config.destination_client
    resource.pop("id", None)
    payload = {"data": resource}
    resp = await destination_client.post(self.resource_config.base_path, payload)
    return _id, resp["data"]

The API returns 409 because an operation with the same name already exists.

Contrast with rum_retention_filters (PR #716)

The rum_retention_filters model (also skip_resource_mapping=True) solved this with destination reconciliation inside create_resource — before POSTing, it lists existing filters at the destination and adopts a matching one via update instead of creating a duplicate. rum_operations lacks this reconciliation.

Suggested fix

Add destination reconciliation to create_resource, similar to rum_retention_filters:

async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
    destination_client = self.config.destination_client
    resource.pop("id", None)
    payload = {"data": resource}

    # Destination reconciliation: check if an operation with the same
    # name already exists before POSTing (skip_resource_mapping=True
    # means the pre-apply listing phase is skipped).
    op_name = resource.get("attributes", {}).get("name", "")
    try:
        existing = await destination_client.post(
            "/api/v2/rum/operations/search",
            {"data": {"attributes": {"page": {"limit": 100}}}},
        )
        for op in existing.get("data", []):
            if op.get("attributes", {}).get("name") == op_name:
                # Adopt existing operation via update
                self.config.state.destination[self.resource_type][_id] = op
                return await self.update_resource(_id, resource)
    except Exception:
        pass  # fall through to create

    resp = await destination_client.post(self.resource_config.base_path, payload)
    return _id, resp["data"]

Alternatively, remove skip_resource_mapping=True so the pre-apply listing phase fetches existing destination operations and the normal create-vs-update decision works.

@michael-richey
michael-richey force-pushed the michael.richey/add-rum-permanent-retention-filters branch from 864e6dd to b66155e Compare October 1, 2026 18:13
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-operations branch from f73ec4c to 67daf21 Compare October 1, 2026 18:13
@michael-richey

Copy link
Copy Markdown
Collaborator Author

Fixed in 67daf218. rum_operations has skip_resource_mapping=True, so the apply pre-pass never lists destination operations. On a second run, create_resource POSTed unconditionally and got 409 Conflict.

Fix: create_resource now searches the destination's operations via the search endpoint before POSTing. If a matching operation is found (same name + application_id), it hydrates state and delegates to update. New test verifies the reconciliation path.

@michael-richey

Copy link
Copy Markdown
Collaborator Author

Re-test Results (after fix 67daf21)

Re-ran the full lifecycle integration test with the updated branch. The destination reconciliation fix is not working — rum_operations still fails with 409:

409 Conflict - {"errors":[{"status":"409","detail":"Operation with this name already exists"}]}

Root cause

The reconciliation in create_resource matches by name AND application_id:

if op_attrs.get("name") == op_name and op_attrs.get("application_id") == app_id:

However, the existing operation at the destination has an application_id from a previous destination app (created by an earlier sync run). The current sync creates a new destination app (since state was wiped), so the remapped application_id is different. The match fails because the old operation's application_id doesn't equal the new app's ID.

Verified via API:

Existing operation: id=7xn-kwa-b8p  name=checkout-flow  application_id=95a4deed-65c8-4043-9efe-52eec5da6476 (old app)
Current sync app:  application_id=ea3a43dd-e7cf-47dd-8469-8b4701ac9b2a (new app)

Suggested fix

Match by name only (operation names are unique within an org), or fall back to name-only matching if name + application_id doesn't match:

for op in existing.get("data", []):
    op_attrs = op.get("attributes", {})
    if op_attrs.get("name") == op_name:
        # Match by name — operation names are unique within an org
        self.config.state.destination[self.resource_type][_id] = op
        return await self.update_resource(_id, resource)

@michael-richey

Copy link
Copy Markdown
Collaborator Author

Fixed in fbd95da0. The reconciliation was matching by name AND application_id, but the existing destination operation has an application_id from a previous destination app (created by an earlier sync run). When state is wiped and a new destination app is created, the old operation's application_id doesn't match the new app's ID.

Fix: match by name only (operation names are unique within an org). Updated test to verify matching works even when the existing op has a different application_id than the current sync.

@michael-richey
michael-richey force-pushed the michael.richey/add-rum-permanent-retention-filters branch from b66155e to 3585b3a Compare October 1, 2026 20:37
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-operations branch from fbd95da to 3a336dd Compare October 1, 2026 20:37
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-permanent-retention-filters branch from 3585b3a to a6191f3 Compare October 1, 2026 21:17
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-operations branch from 3a336dd to bcf4158 Compare October 1, 2026 21:17
RUM operations are static definitions (name, display_name, category, query,
journey rules) tied to a RUM application via attributes.application_id (a real
body field, remapped to the destination app id via resource_connections). The
query fields in the journey are RUM query filters (e.g. @type:view), not
session ids, so no session-id stripping is required -- the feasibility spike
confirmed operations are clean static definitions.

List is via the /rum/operations/search GET endpoint; create is POST, update
is PUT, delete is DELETE. Create data has no id (server-assigned); update sets
id to the destination id.

- datadog_sync/model/rum_operations.py (new)
- datadog_sync/models/__init__.py -- register
- tests/unit/test_rum_operations.py (new) -- 7 unit tests
- README.md -- add rum_operations (depends on rum_applications)

Integration tests + VCR cassettes deferred (require sandbox-org API access).
Per review: /rum/operations/search is paginated but get_resources only
returned the first page. Switch to client.paginated_request with
PaginationConfig (page[limit]=100, page[offset] incrementing by page_size)
so all operations are imported, not just the first page. Test updated to
verify paginated_request is used.
Per integration test: rum_operations has skip_resource_mapping=True, so the
apply pre-pass never lists destination operations. On a second run,
create_resource POSTs unconditionally and gets 409 Conflict.

Fix: create_resource now searches the destination's operations (via the
search endpoint) before POSTing. If a matching operation is found (same
name + application_id), it hydrates state and delegates to update instead
of creating a duplicate. New test verifies the reconciliation path.
Per integration test re-test: the reconciliation in create_resource matched
by name AND application_id, but the existing destination operation has an
application_id from a previous destination app (created by an earlier sync
run). When state is wiped and a new destination app is created, the old
operation's application_id doesn't match the new app's ID, so the match
fails and the sync POSTs unconditionally (409 Conflict).

Fix: match by name only (operation names are unique within an org). Updated
test to verify matching works even when the existing op has a different
application_id than the current sync.
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-permanent-retention-filters branch from a6191f3 to 46857db Compare October 2, 2026 01:45
@michael-richey
michael-richey force-pushed the michael.richey/add-rum-operations branch from bcf4158 to dcf0952 Compare October 2, 2026 01:45
@michael-richey

Copy link
Copy Markdown
Collaborator Author

Great model and tests overall. I found one blocker in the 409 reconciliation path:

Reconciliation search is not paginated

On create conflict, we do a single GET on /api/v2/rum/operations/search and scan the returned page for a matching operation name. This endpoint is paginated (page[offset], page[limit], max limit 100).

In orgs with enough operations, the conflicting operation may be outside the first page:

  • lookup misses it,
  • code raises ValueError ("Could not locate conflicting RUM operation"),
  • sync fails even though the operation exists.

Suggested fix:

  • Reuse paginated_request for the reconciliation lookup (same pagination config as get_resources), or
  • Retry with paginated search before raising.

I’d consider this a blocker for large-org reliability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants