From 4c904c170db282f2786e36a603bcbc26cfc98bcd Mon Sep 17 00:00:00 2001 From: "riyaz.shiraguppi" Date: Wed, 15 Jul 2026 15:05:57 -0400 Subject: [PATCH] fix(metrics_metadata): skip distribution-typed metrics in update path The destination rejects PUT /api/v1/metrics/{name} for type=distribution with 400 at the DB layer, so the request cannot succeed. Skip early to avoid burning retries. Case- and whitespace-tolerant match. Fires before the destination-existence probe. import_resource unchanged. --- datadog_sync/model/metrics_metadata.py | 19 +++++++++++ tests/unit/test_metrics_metadata.py | 46 +++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) 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