Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
135 changes: 130 additions & 5 deletions datadog_sync/model/rum_applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -66,12 +101,74 @@ 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}")

# 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]:
destination_client = self.config.destination_client
Expand All @@ -84,6 +181,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
Expand All @@ -92,10 +192,35 @@ 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. 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:
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}")
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand All @@ -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
Loading
Loading