diff --git a/datadog_sync/model/metrics_metadata.py b/datadog_sync/model/metrics_metadata.py index c5b71474..b69367a2 100644 --- a/datadog_sync/model/metrics_metadata.py +++ b/datadog_sync/model/metrics_metadata.py @@ -54,6 +54,25 @@ async def create_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: async def update_resource(self, _id: str, resource: Dict) -> Tuple[str, Dict]: destination_client = self.config.destination_client + # Distribution metrics cannot be written via PUT /api/v1/metrics/{name} + # — the destination rejects type=distribution with a 400 at the DB + # layer. Skip early so the run does not burn retries on a request + # that will never succeed. Skip fires before the destination probe + # below to avoid a wasted GET. + # + # Case-tolerant match: the destination canonicalizes to lowercase but + # normalize defensively so an upstream drift in the source-side casing + # cannot silently re-enable the 400 loop. + metric_type = (resource.get("type") or "").strip().lower() + if metric_type == "distribution": + log.debug(f"[metrics_metadata - {_id}] skipping: distribution type not writable via public PUT") + raise SkipResource( + _id, + self.resource_type, + "distribution type is rejected by the destination metrics_metadata endpoint; " + "skipping public PUT", + ) + # metrics_metadata can only attach to a metric that already exists on # destination. Legacy dogweb returns 400 {"errors":["error updating metric # metadata"]} when the metric is missing; that failure adds ~25-30min of diff --git a/tests/unit/test_metrics_metadata.py b/tests/unit/test_metrics_metadata.py index 03a95b14..54c0932b 100644 --- a/tests/unit/test_metrics_metadata.py +++ b/tests/unit/test_metrics_metadata.py @@ -3,7 +3,7 @@ # This product includes software developed at Datadog (https://www.datadoghq.com/). # Copyright 2019 Datadog, Inc. -"""Unit tests for metrics_metadata destination-existence filter (HAMR-392 Jul8-T20).""" +"""Unit tests for metrics_metadata update_resource filters.""" import asyncio from types import SimpleNamespace @@ -82,6 +82,50 @@ def test_update_resource_get_403_propagates(metrics_metadata): client.put.assert_not_awaited() +@pytest.mark.parametrize( + "type_value", + ["distribution", "Distribution", "DISTRIBUTION", "distribution "], + ids=["lowercase", "capitalized", "uppercase", "trailing_space"], +) +def test_update_resource_distribution_type_raises_skip(metrics_metadata, type_value): + """Distribution-typed metrics skip early; no destination HTTP call is made. + Case + whitespace variants must all skip so an upstream drift in the source + canonicalization cannot silently re-enable the 400 loop. + """ + client = metrics_metadata.config.destination_client + client.get = AsyncMock() + client.put = AsyncMock() + + with pytest.raises(SkipResource) as exc_info: + asyncio.run(metrics_metadata.update_resource("some.dist.metric", {"type": type_value, "description": "x"})) + + assert "some.dist.metric" in str(exc_info.value) + assert "distribution" in str(exc_info.value) + client.get.assert_not_awaited() + client.put.assert_not_awaited() + + +@pytest.mark.parametrize("type_value", ["count", "rate", "gauge", "histogram", None]) +def test_update_resource_non_distribution_type_probes_destination(metrics_metadata, type_value): + """Regression: non-distribution types (and missing type) still probe destination via GET before PUT. + The v1 probe endpoint returns a flat metric metadata dict; the return value is unused (only + non-404 status matters), so any non-None dict works as a mock. + """ + client = metrics_metadata.config.destination_client + client.get = AsyncMock(return_value={"type": type_value or "count", "description": "existing"}) + client.put = AsyncMock(return_value={"description": "updated"}) + + resource = {"description": "updated"} + if type_value is not None: + resource["type"] = type_value + + _id, _ = asyncio.run(metrics_metadata.update_resource("some.metric", resource)) + + assert _id == "some.metric" + client.get.assert_awaited_once() + client.put.assert_awaited_once() + + def test_create_resource_delegates_to_update(metrics_metadata): """create_resource remains a thin wrapper around update_resource (behaviour unchanged).""" client = metrics_metadata.config.destination_client