Skip to content
Merged
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
20 changes: 19 additions & 1 deletion datadog_sync/model/metric_percentiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@

from datadog_sync.utils.base_resource import BaseResource, ResourceConfig
from datadog_sync.utils.custom_client import CustomClient
from datadog_sync.utils.resource_utils import CustomClientHTTPError, SkipResource


def _error_body(error: CustomClientHTTPError) -> str:
return (error.response_body or "").lower()


def _is_metric_not_found_error(error: CustomClientHTTPError) -> bool:
return error.status_code in (400, 404, 500) and "metric not found" in _error_body(error)


class MetricPercentiles(BaseResource):
Expand Down Expand Up @@ -56,7 +65,16 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
# returns 403 empty-body at the OBO auth layer.
destination_client = self.config.destination_client
path = self.enable_percentiles_path if resource.get("include_percentiles") else self.disable_percentiles_path
await destination_client.patch(path, {"metric_names": [_id]})
try:
await destination_client.patch(path, {"metric_names": [_id]})
except CustomClientHTTPError as e:
if _is_metric_not_found_error(e):
raise SkipResource(
_id,
self.resource_type,
"Metric not present on destination; percentiles cannot attach.",
)
raise

return _id, resource

Expand Down
51 changes: 43 additions & 8 deletions datadog_sync/model/metric_tag_configurations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,24 @@
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, SkipResource

if TYPE_CHECKING:
from datadog_sync.utils.custom_client import CustomClient


def _error_body(error: CustomClientHTTPError) -> str:
return (error.response_body or "").lower()


def _is_missing_metric_error(error: CustomClientHTTPError) -> bool:
return error.status_code == 400 and "metric that does not exist" in _error_body(error)


def _is_existing_tag_config_conflict(error: CustomClientHTTPError) -> bool:
return error.status_code == 409 and "patch" in _error_body(error)


class MetricTagConfigurations(BaseResource):
resource_type = "metric_tag_configurations"
resource_config = ResourceConfig(
Expand Down Expand Up @@ -47,10 +60,22 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:

destination_client = self.config.destination_client
payload = {"data": resource}
resp = await destination_client.post(
self.resource_config.base_path + f"/{self.config.state.source[self.resource_type][_id]['id']}/tags",
payload,
)
path = self.resource_config.base_path + f"/{self.config.state.source[self.resource_type][_id]['id']}/tags"
try:
resp = await destination_client.post(path, payload)
except CustomClientHTTPError as e:
if _is_missing_metric_error(e):
raise SkipResource(
_id,
self.resource_type,
"Metric not present on destination; tag configuration cannot attach.",
)
if not _is_existing_tag_config_conflict(e):
raise

existing = await destination_client.get(path)
self.config.state.destination[self.resource_type][_id] = existing["data"]
return await self.update_resource(_id, resource)

return _id, resp["data"]

Expand All @@ -59,10 +84,20 @@ async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]:
if "attributes" in resource:
resource["attributes"].pop("metric_type", None)
payload = {"data": resource}
resp = await destination_client.patch(
self.resource_config.base_path + f"/{self.config.state.destination[self.resource_type][_id]['id']}/tags",
payload,
)
try:
resp = await destination_client.patch(
self.resource_config.base_path
+ f"/{self.config.state.destination[self.resource_type][_id]['id']}/tags",
payload,
)
except CustomClientHTTPError as e:
if _is_missing_metric_error(e):
raise SkipResource(
_id,
self.resource_type,
"Metric not present on destination; tag configuration cannot attach.",
)
raise

return _id, resp["data"]

Expand Down
135 changes: 135 additions & 0 deletions tests/unit/test_metric_percentiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# 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.

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

from datadog_sync.model.metric_percentiles import MetricPercentiles
from datadog_sync.utils.resource_utils import CustomClientHTTPError, SkipResource


def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()


def _http_error(status: int, message: str = "err") -> CustomClientHTTPError:
return CustomClientHTTPError(SimpleNamespace(status=status, message="err"), message=message)


@pytest.fixture
def metric_percentiles(mock_config):
mock_config.destination_client = AsyncMock()
return MetricPercentiles(mock_config)


def test_update_resource_existing_metric_enables_percentiles(metric_percentiles):
client = metric_percentiles.config.destination_client
client.get = AsyncMock()
client.patch = AsyncMock(return_value={})

_id, resource = _run(
metric_percentiles.update_resource("custom.metric", {"metric": "custom.metric", "include_percentiles": True})
)

assert _id == "custom.metric"
assert resource == {"metric": "custom.metric", "include_percentiles": True}
client.get.assert_not_awaited()
client.patch.assert_awaited_once_with(
"/metric/distribution/summary_aggr/percentiles/enable",
{"metric_names": ["custom.metric"]},
)


def test_update_resource_existing_metric_disables_percentiles(metric_percentiles):
client = metric_percentiles.config.destination_client
client.get = AsyncMock()
client.patch = AsyncMock(return_value={})

_run(metric_percentiles.update_resource("custom.metric", {"metric": "custom.metric", "include_percentiles": False}))

client.get.assert_not_awaited()
client.patch.assert_awaited_once_with(
"/metric/distribution/summary_aggr/percentiles/disable",
{"metric_names": ["custom.metric"]},
)


def test_update_resource_missing_destination_metric_patch_raises_skip(metric_percentiles):
client = metric_percentiles.config.destination_client
client.get = AsyncMock()
client.patch = AsyncMock(side_effect=_http_error(404, '{"errors":["custom.metric not found"]}'))

with pytest.raises(SkipResource) as exc_info:
_run(
metric_percentiles.update_resource(
"custom.metric",
{"metric": "custom.metric", "include_percentiles": True},
)
)

assert "custom.metric" in str(exc_info.value)
assert "not present on destination" in str(exc_info.value)
client.get.assert_not_awaited()
client.patch.assert_awaited_once()


def test_update_resource_metric_not_found_patch_raises_skip(metric_percentiles):
client = metric_percentiles.config.destination_client
client.get = AsyncMock()
client.patch = AsyncMock(side_effect=_http_error(500, '{"detail":"metric not found"}'))

with pytest.raises(SkipResource) as exc_info:
_run(
metric_percentiles.update_resource(
"custom.metric",
{"metric": "custom.metric", "include_percentiles": True},
)
)

assert "custom.metric" in str(exc_info.value)
assert "not present on destination" in str(exc_info.value)
client.get.assert_not_awaited()
client.patch.assert_awaited_once()


def test_update_resource_non_metric_not_found_400_patch_error_propagates(metric_percentiles):
client = metric_percentiles.config.destination_client
client.get = AsyncMock()
client.patch = AsyncMock(side_effect=_http_error(400, "Bad Request"))

with pytest.raises(CustomClientHTTPError) as exc_info:
_run(
metric_percentiles.update_resource(
"custom.metric",
{"metric": "custom.metric", "include_percentiles": True},
)
)

assert exc_info.value.status_code == 400
client.get.assert_not_awaited()


def test_update_resource_non_metric_not_found_patch_error_propagates(metric_percentiles):
client = metric_percentiles.config.destination_client
client.get = AsyncMock()
client.patch = AsyncMock(side_effect=_http_error(500, "Internal Server Error"))

with pytest.raises(CustomClientHTTPError) as exc_info:
_run(
metric_percentiles.update_resource(
"custom.metric",
{"metric": "custom.metric", "include_percentiles": True},
)
)

assert exc_info.value.status_code == 500
client.get.assert_not_awaited()
138 changes: 138 additions & 0 deletions tests/unit/test_metric_tag_configurations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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.

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

from datadog_sync.model.metric_tag_configurations import MetricTagConfigurations
from datadog_sync.utils.resource_utils import CustomClientHTTPError, SkipResource


def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()


def _http_error(status: int, message: str = "err") -> CustomClientHTTPError:
return CustomClientHTTPError(SimpleNamespace(status=status, message="err"), message=message)


def _resource(metric_name: str = "custom.metric") -> dict:
return {
"id": metric_name,
"type": "manage_tags",
"attributes": {"metric_type": "count", "tags": ["env", "service"]},
}


@pytest.fixture
def metric_tag_configurations(mock_config):
mock_config.destination_client = AsyncMock()
return MetricTagConfigurations(mock_config)


def test_create_resource_happy_path(metric_tag_configurations):
client = metric_tag_configurations.config.destination_client
client.post = AsyncMock(return_value={"data": _resource()})
client.get = AsyncMock()
client.patch = AsyncMock()
metric_tag_configurations.config.state.source["metric_tag_configurations"]["custom.metric"] = _resource()

_id, data = _run(metric_tag_configurations.create_resource("custom.metric", _resource()))

assert _id == "custom.metric"
assert data == _resource()
client.post.assert_awaited_once_with("/api/v2/metrics/custom.metric/tags", {"data": _resource()})
client.get.assert_not_awaited()
client.patch.assert_not_awaited()


def test_create_resource_missing_destination_metric_raises_skip(metric_tag_configurations):
client = metric_tag_configurations.config.destination_client
client.post = AsyncMock(side_effect=_http_error(400, "Cannot configure tags on a metric that does not exist"))
client.get = AsyncMock()
client.patch = AsyncMock()
metric_tag_configurations.config.state.source["metric_tag_configurations"]["missing.metric"] = _resource(
"missing.metric"
)

with pytest.raises(SkipResource) as exc_info:
_run(metric_tag_configurations.create_resource("missing.metric", _resource("missing.metric")))

assert "missing.metric" in str(exc_info.value)
assert "not present on destination" in str(exc_info.value)
client.post.assert_awaited_once()
client.get.assert_not_awaited()
client.patch.assert_not_awaited()


def test_create_resource_existing_config_conflict_gets_existing_then_patches(metric_tag_configurations):
client = metric_tag_configurations.config.destination_client
client.post = AsyncMock(side_effect=_http_error(409, "Conflicts with existing configuration; use PATCH to update"))
client.get = AsyncMock(return_value={"data": {"id": "custom.metric", "attributes": {"tags": ["env"]}}})
client.patch = AsyncMock(return_value={"data": {"id": "custom.metric", "attributes": {"tags": ["env", "service"]}}})
metric_tag_configurations.config.state.source["metric_tag_configurations"]["custom.metric"] = _resource()

_id, data = _run(metric_tag_configurations.create_resource("custom.metric", _resource()))

client.post.assert_awaited_once()
client.get.assert_awaited_once_with("/api/v2/metrics/custom.metric/tags")
assert metric_tag_configurations.config.state.destination["metric_tag_configurations"]["custom.metric"] == {
"id": "custom.metric",
"attributes": {"tags": ["env"]},
}
client.patch.assert_awaited_once()
patch_url = client.patch.await_args.args[0]
assert patch_url == "/api/v2/metrics/custom.metric/tags"
assert client.patch.await_args.args[1]["data"]["attributes"] == {"tags": ["env", "service"]}
assert _id == "custom.metric"
assert data == {"id": "custom.metric", "attributes": {"tags": ["env", "service"]}}


def test_create_resource_non_matching_409_propagates(metric_tag_configurations):
client = metric_tag_configurations.config.destination_client
client.post = AsyncMock(side_effect=_http_error(409, "conflict"))
client.get = AsyncMock()
client.patch = AsyncMock()
metric_tag_configurations.config.state.source["metric_tag_configurations"]["custom.metric"] = _resource()

with pytest.raises(CustomClientHTTPError) as exc_info:
_run(metric_tag_configurations.create_resource("custom.metric", _resource()))

assert exc_info.value.status_code == 409
client.get.assert_not_awaited()
client.patch.assert_not_awaited()


def test_update_resource_missing_destination_metric_raises_skip(metric_tag_configurations):
client = metric_tag_configurations.config.destination_client
client.patch = AsyncMock(side_effect=_http_error(400, "Cannot configure tags on a metric that does not exist"))
metric_tag_configurations.config.state.destination["metric_tag_configurations"]["missing.metric"] = _resource(
"missing.metric"
)

with pytest.raises(SkipResource) as exc_info:
_run(metric_tag_configurations.update_resource("missing.metric", _resource("missing.metric")))

assert "missing.metric" in str(exc_info.value)
assert "not present on destination" in str(exc_info.value)
client.patch.assert_awaited_once()


def test_update_resource_non_missing_metric_error_propagates(metric_tag_configurations):
client = metric_tag_configurations.config.destination_client
client.patch = AsyncMock(side_effect=_http_error(500, "Internal Server Error"))
metric_tag_configurations.config.state.destination["metric_tag_configurations"]["custom.metric"] = _resource()

with pytest.raises(CustomClientHTTPError) as exc_info:
_run(metric_tag_configurations.update_resource("custom.metric", _resource()))

assert exc_info.value.status_code == 500
Loading