From 16db60506403953dd59caf6dfef926fd03943dc9 Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Wed, 19 Aug 2026 09:59:13 -0400 Subject: [PATCH 01/10] move parser to go --- .../base/checks/openmetrics/v2/go_parser.py | 133 ++++++++++++++++++ .../openmetrics/v2/scraper/base_scraper.py | 52 +++++-- .../openmetrics/v2/scraper/decorators.py | 2 +- .../base/checks/openmetrics/v2/utils.py | 2 +- .../base/stubs/datadog_agent.py | 113 +++++++++++++++ 5 files changed, 285 insertions(+), 17 deletions(-) create mode 100644 datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py new file mode 100644 index 0000000000000..32d0ba5f112e5 --- /dev/null +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -0,0 +1,133 @@ +# (C) Datadog, Inc. 2025-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import json +import logging +from itertools import islice +from typing import TYPE_CHECKING + +from datadog_checks.base.agent import datadog_agent + +if TYPE_CHECKING: + from collections.abc import Iterator + +log = logging.getLogger(__name__) + + +class Sample: + """Drop-in replacement for prometheus_client.samples.Sample. + + Attributes are mutable so that downstream code (label normalization, + histogram decumulation, code-class injection) can modify labels in place. + Constructable with positional args ``(name, labels, value)`` for + compatibility with ``decumulate_histogram_buckets``. + """ + + __slots__ = ('name', 'labels', 'value', 'timestamp', 'exemplar') + + def __init__( + self, + name: str, + labels: dict[str, str], + value: float, + timestamp: float | None = None, + exemplar: object | None = None, + ): + self.name = name + self.labels = labels + self.value = value + self.timestamp = timestamp + self.exemplar = exemplar + + def __repr__(self): + return f"Sample(name={self.name!r}, labels={self.labels!r}, value={self.value!r})" + + +class Metric: + """Drop-in replacement for prometheus_client.metrics_core.Metric. + + Only the attributes consumed by the V2 pipeline are provided. + """ + + __slots__ = ('name', 'type', 'documentation', 'samples') + + def __init__(self, name: str, type: str, documentation: str, samples: list[Sample]): + self.name = name + self.type = type + self.documentation = documentation + self.samples = samples + + def __repr__(self): + return f"Metric(name={self.name!r}, type={self.type!r}, samples={len(self.samples)})" + + +def batched_lines(line_iter: Iterator[str], target_size: int = 128) -> Iterator[str]: + """Yield batches of lines joined with newlines. + + Each batch contains up to ``target_size`` lines, joined into a single + string. This amortizes CGo call overhead when feeding the Go parser. + """ + while True: + batch = list(islice(line_iter, target_size)) + if not batch: + break + yield '\n'.join(batch) + + +def _json_to_metric(family: dict) -> Metric: + samples = [ + Sample( + s['name'], + s.get('labels') or {}, + s['value'], + s.get('timestamp'), + s.get('exemplar'), + ) + for s in family.get('samples', ()) + ] + return Metric( + family['name'], + family.get('type', 'untyped'), + family.get('help', ''), + samples, + ) + + +def parse_with_go_parser(content_type: str, line_streamer: Iterator[str]) -> Iterator[Metric]: + """Parse prometheus/openmetrics text using the Go parser exposed via ``datadog_agent``. + + This is a drop-in replacement for ``text_fd_to_metric_families`` that + delegates the actual text parsing to Go for better performance while + preserving the Python streaming pipeline's memory characteristics. + + The Go parser is stateful: ``new_prometheus_parser`` creates a parser + handle, ``feed_prometheus_parser`` sends a batch of lines and returns + any complete metric families parsed so far, and ``finish_prometheus_parser`` + flushes remaining data and releases the handle. + """ + parser_id = datadog_agent.new_prometheus_parser(content_type) + try: + for chunk in batched_lines(line_streamer, target_size=128): + families_json = datadog_agent.feed_prometheus_parser(parser_id, chunk) + if families_json: + for family in json.loads(families_json): + yield _json_to_metric(family) + + remaining_json = datadog_agent.finish_prometheus_parser(parser_id) + if remaining_json: + for family in json.loads(remaining_json): + yield _json_to_metric(family) + except GeneratorExit: + # Generator was closed before finishing; clean up the Go-side parser. + try: + datadog_agent.finish_prometheus_parser(parser_id) + except Exception: + pass + except Exception: + try: + datadog_agent.finish_prometheus_parser(parser_id) + except Exception: + pass + raise diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/base_scraper.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/base_scraper.py index ee4c6688b1d32..4a6b989f86b25 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/base_scraper.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/base_scraper.py @@ -10,14 +10,11 @@ from math import isinf, isnan from typing import List # noqa: F401 -from prometheus_client import Metric -from prometheus_client.openmetrics.parser import text_fd_to_metric_families as parse_openmetrics -from prometheus_client.parser import text_fd_to_metric_families as parse_prometheus from requests.exceptions import ConnectionError from datadog_checks.base.agent import datadog_agent -from datadog_checks.base.checks.openmetrics import parser_optimizations from datadog_checks.base.checks.openmetrics.v2.first_scrape_handler import first_scrape_handler +from datadog_checks.base.checks.openmetrics.v2.go_parser import Metric, parse_with_go_parser from datadog_checks.base.checks.openmetrics.v2.labels import LabelAggregator, get_label_normalizer from datadog_checks.base.checks.openmetrics.v2.transform import MetricTransformer from datadog_checks.base.config import is_affirmative @@ -26,6 +23,27 @@ from datadog_checks.base.utils.functions import no_op, return_true from datadog_checks.base.utils.http import RequestsWrapper +# Lazy-loaded Python prometheus_client parsers, used as fallback when the Go parser is unavailable or broken. +_parse_openmetrics = None +_parse_prometheus = None + + +def _get_python_parser(use_openmetrics: bool): + """Load the Python prometheus_client parser on demand.""" + global _parse_openmetrics, _parse_prometheus + if use_openmetrics: + if _parse_openmetrics is None: + from prometheus_client.openmetrics.parser import text_fd_to_metric_families + + _parse_openmetrics = text_fd_to_metric_families + return _parse_openmetrics + else: + if _parse_prometheus is None: + from prometheus_client.parser import text_fd_to_metric_families + + _parse_prometheus = text_fd_to_metric_families + return _parse_prometheus + class OpenMetricsScraper: """ @@ -233,7 +251,7 @@ def __init__(self, check, config): self.use_process_start_time = is_affirmative(config.get('use_process_start_time')) - parser_optimizations.init_from_agent_config() + self._use_go_parser = is_affirmative(config.get('use_go_parser', True)) # Used for monotonic counts self.flush_first_value = None @@ -351,16 +369,20 @@ def parse_metrics(self): @property def parse_metric_families(self): - media_type = self._content_type.split(';')[0] - # Setting `use_latest_spec` forces the use of the OpenMetrics format, otherwise - # the format will be chosen based on the media type specified in the response's content-header. - # The selection is based on what Prometheus does: - # https://github.com/prometheus/prometheus/blob/v2.43.0/model/textparse/interface.go#L83-L90 - return ( - parse_openmetrics - if self._use_latest_spec or media_type == 'application/openmetrics-text' - else parse_prometheus - ) + # When use_latest_spec is set, force OpenMetrics format regardless of the + # actual Content-Type header returned by the endpoint. + if self._use_latest_spec: + content_type = 'application/openmetrics-text' + else: + content_type = self._content_type + + if self._use_go_parser: + return lambda lines: parse_with_go_parser(content_type, lines) + + # Fallback to the Python prometheus_client parser. + media_type = content_type.split(';')[0] + use_openmetrics = media_type == 'application/openmetrics-text' + return _get_python_parser(use_openmetrics) def generate_sample_data(self, metric): """ diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/decorators.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/decorators.py index 477b98c601a7c..05af9c8398293 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/decorators.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/scraper/decorators.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from collections.abc import Generator - from prometheus_client.metrics_core import Metric + from datadog_checks.base.checks.openmetrics.v2.go_parser import Metric class WithHttpCodeClass(OpenMetricsScraper): diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/utils.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/utils.py index eb44d81cf358b..ad416bb698323 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/utils.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/utils.py @@ -1,7 +1,7 @@ # (C) Datadog, Inc. 2020-present # All rights reserved # Licensed under a 3-clause BSD style license (see LICENSE) -from prometheus_client.samples import Sample +from datadog_checks.base.checks.openmetrics.v2.go_parser import Sample NEGATIVE_INFINITY = float('-inf') diff --git a/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py b/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py index 916fe11ebd994..3af8be89b3056 100644 --- a/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py +++ b/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py @@ -182,6 +182,119 @@ def report_issue(self, check_name, report_json): def resolve_issue(self, issue_id): self._sent_resolved_issues.append(issue_id) + # Prometheus parser stub — uses the Python prometheus_client library + # to implement the same API that Go exposes in production. + + def __init_parser_state(self): + if not hasattr(self, '_prometheus_parsers'): + self._prometheus_parsers = {} + self._prometheus_parser_counter = 0 + + def new_prometheus_parser(self, content_type): + self.__init_parser_state() + self._prometheus_parser_counter += 1 + parser_id = self._prometheus_parser_counter + self._prometheus_parsers[parser_id] = { + 'content_type': content_type, + 'buffer': '', + } + return parser_id + + def feed_prometheus_parser(self, parser_id, chunk): + self.__init_parser_state() + parser_state = self._prometheus_parsers.get(parser_id) + if parser_state is None: + raise ValueError(f'Unknown parser id: {parser_id}') + + buf = parser_state['buffer'] + if buf: + buf += '\n' + chunk + else: + buf = chunk + + # Find the last metric family boundary where a new family starts + # (a line beginning with '# HELP' or '# TYPE'). Only split there + # if the text *before* the boundary contains at least one sample + # line (non-empty, non-comment), otherwise we'd emit an empty + # parse and lose the actual family. + lines = buf.split('\n') + last_boundary = -1 + for i in range(len(lines) - 1, 0, -1): + if lines[i].startswith('# HELP ') or lines[i].startswith('# TYPE '): + # Check that there is at least one sample line before this boundary. + has_sample = any(line and not line.startswith('#') for line in lines[:i]) + if has_sample: + last_boundary = i + break + + if last_boundary <= 0: + # No complete family yet, buffer everything. + parser_state['buffer'] = buf + return '' + + complete = '\n'.join(lines[:last_boundary]) + parser_state['buffer'] = '\n'.join(lines[last_boundary:]) + + return self._parse_prometheus_text(complete, parser_state['content_type']) + + def finish_prometheus_parser(self, parser_id): + self.__init_parser_state() + parser_state = self._prometheus_parsers.pop(parser_id, None) + if parser_state is None: + raise ValueError(f'Unknown parser id: {parser_id}') + + buf = parser_state['buffer'] + if not buf or not buf.strip(): + return '' + + return self._parse_prometheus_text(buf, parser_state['content_type']) + + @staticmethod + def _is_openmetrics(content_type): + media_type = content_type.split(';')[0] if content_type else '' + return media_type == 'application/openmetrics-text' + + @staticmethod + def _parse_prometheus_text(text, content_type): + if DatadogAgentStub._is_openmetrics(content_type): + from prometheus_client.openmetrics.parser import text_fd_to_metric_families + + # OpenMetrics format requires # EOF terminator; strip any existing + # one and re-add it so intermediate chunks parse correctly. + lines = [line for line in text.split('\n') if line.strip() != '# EOF'] + lines.append('# EOF') + text = '\n'.join(lines) + else: + from prometheus_client.parser import text_fd_to_metric_families + + families = [] + for metric in text_fd_to_metric_families(iter(text.split('\n'))): + samples = [] + for s in metric.samples: + sample = { + 'name': s.name if hasattr(s, 'name') else s[0], + 'labels': dict(s.labels if hasattr(s, 'labels') else s[1]), + 'value': s.value if hasattr(s, 'value') else s[2], + } + ts = getattr(s, 'timestamp', None) if hasattr(s, 'timestamp') else None + if ts is not None: + sample['timestamp'] = ts + exemplar = getattr(s, 'exemplar', None) if hasattr(s, 'exemplar') else None + if exemplar is not None: + sample['exemplar'] = exemplar + samples.append(sample) + if samples: + families.append( + { + 'name': metric.name, + 'type': metric.type, + 'help': metric.documentation, + 'samples': samples, + } + ) + + return json.encode(families) if families else '' + # Use the stub as a singleton datadog_agent = DatadogAgentStub() From 6821bf057d3e727b5c024e9b0865f5892a3aa0da Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Wed, 19 Aug 2026 13:20:53 -0400 Subject: [PATCH 02/10] handle nan and inf --- .../base/checks/openmetrics/v2/go_parser.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py index 32d0ba5f112e5..1d1a41168a017 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -76,12 +76,26 @@ def batched_lines(line_iter: Iterator[str], target_size: int = 128) -> Iterator[ yield '\n'.join(batch) +_NAN_INF_MAP = {'NaN': float('nan'), '+Inf': float('inf'), '-Inf': float('-inf')} + + +def _decode_value(v: float | str) -> float: + """Decode a sample value from the Go parser. + + The Go parser encodes NaN and ±Inf as JSON strings to work around + encoding/json's rejection of non-finite floats. + """ + if isinstance(v, str): + return _NAN_INF_MAP[v] + return v + + def _json_to_metric(family: dict) -> Metric: samples = [ Sample( s['name'], s.get('labels') or {}, - s['value'], + _decode_value(s['value']), s.get('timestamp'), s.get('exemplar'), ) From 47ef23325f28068aeea391f59dcafb3db79114fd Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Wed, 19 Aug 2026 13:40:25 -0400 Subject: [PATCH 03/10] use test agent image --- ddev/src/ddev/config/model.py | 2 +- ddev/src/ddev/e2e/agent/image.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ddev/src/ddev/config/model.py b/ddev/src/ddev/config/model.py index 5a1c9ebe986b5..f188bc72dd10b 100644 --- a/ddev/src/ddev/config/model.py +++ b/ddev/src/ddev/config/model.py @@ -206,7 +206,7 @@ def agents(self): self._field_agents = agents else: self._field_agents = self.raw_data['agents'] = { - 'dev': {'docker': 'registry.datadoghq.com/agent-dev:master-py3', 'local': 'latest'}, + 'dev': {'docker': 'datadog/agent-dev:sarah-parser-go-client-py3', 'local': 'latest'}, '7': {'docker': 'registry.datadoghq.com/agent:7', 'local': '7'}, } diff --git a/ddev/src/ddev/e2e/agent/image.py b/ddev/src/ddev/e2e/agent/image.py index 6648c09e61904..0cdd611fbac82 100644 --- a/ddev/src/ddev/e2e/agent/image.py +++ b/ddev/src/ddev/e2e/agent/image.py @@ -18,7 +18,10 @@ def normalize_agent_image_name(agent_build: str | None, python_major: int, use_jmx: bool) -> str: if not agent_build: - return 'registry.datadoghq.com/agent-dev:master-py3' + base = 'datadog/agent-dev:sarah-parser-go-client-py3' + if use_jmx: + base += '-jmx' + return base if match := re.match(AGENT_IMAGE_REGEX, agent_build): org, image, tag = match.groups() @@ -30,6 +33,8 @@ def normalize_agent_image_name(agent_build: str | None, python_major: int, use_j version_match = re.match(AGENT_VERSION_REGEX, tag) if version_match is None: # The tag does not follow a recognized Agent version format. + if use_jmx and image == 'agent-dev' and '-jmx' not in tag: + return f'{org}/{image}:{tag}-jmx' return agent_build version = version_match.group('version') From e2943957a26d79e8c3adb92f30dc7f9edb66e99e Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Wed, 19 Aug 2026 13:48:11 -0400 Subject: [PATCH 04/10] update image --- .github/workflows/test-target.yml | 2 +- ddev/src/ddev/config/model.py | 2 +- ddev/src/ddev/e2e/agent/image.py | 10 +++------- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-target.yml b/.github/workflows/test-target.yml index 5fa7b0d8c9ec9..a2ce99fc22344 100644 --- a/.github/workflows/test-target.yml +++ b/.github/workflows/test-target.yml @@ -56,7 +56,7 @@ on: type: boolean agent-image: required: false - default: "registry.datadoghq.com/agent-dev:master-py3" + default: "datadog/agent-dev:sarah-parser-go-client-py3" type: string agent-image-py2: required: false diff --git a/ddev/src/ddev/config/model.py b/ddev/src/ddev/config/model.py index f188bc72dd10b..5a1c9ebe986b5 100644 --- a/ddev/src/ddev/config/model.py +++ b/ddev/src/ddev/config/model.py @@ -206,7 +206,7 @@ def agents(self): self._field_agents = agents else: self._field_agents = self.raw_data['agents'] = { - 'dev': {'docker': 'datadog/agent-dev:sarah-parser-go-client-py3', 'local': 'latest'}, + 'dev': {'docker': 'registry.datadoghq.com/agent-dev:master-py3', 'local': 'latest'}, '7': {'docker': 'registry.datadoghq.com/agent:7', 'local': '7'}, } diff --git a/ddev/src/ddev/e2e/agent/image.py b/ddev/src/ddev/e2e/agent/image.py index 0cdd611fbac82..d22923c56b156 100644 --- a/ddev/src/ddev/e2e/agent/image.py +++ b/ddev/src/ddev/e2e/agent/image.py @@ -17,11 +17,9 @@ def normalize_agent_image_name(agent_build: str | None, python_major: int, use_jmx: bool) -> str: - if not agent_build: - base = 'datadog/agent-dev:sarah-parser-go-client-py3' - if use_jmx: - base += '-jmx' - return base + agent_build = 'datadog/agent-dev:sarah-parser-go-client-py3' + if use_jmx: + agent_build += '-jmx' if match := re.match(AGENT_IMAGE_REGEX, agent_build): org, image, tag = match.groups() @@ -33,8 +31,6 @@ def normalize_agent_image_name(agent_build: str | None, python_major: int, use_j version_match = re.match(AGENT_VERSION_REGEX, tag) if version_match is None: # The tag does not follow a recognized Agent version format. - if use_jmx and image == 'agent-dev' and '-jmx' not in tag: - return f'{org}/{image}:{tag}-jmx' return agent_build version = version_match.group('version') From c9372fa9ce7d06d4ccf2189f663f26ee15a3eb5d Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Thu, 20 Aug 2026 09:32:50 -0400 Subject: [PATCH 05/10] fix total --- .../base/checks/openmetrics/v2/go_parser.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py index 1d1a41168a017..60a772fc12bc2 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -101,9 +101,17 @@ def _json_to_metric(family: dict) -> Metric: ) for s in family.get('samples', ()) ] + name = family['name'] + metric_type = family.get('type', 'untyped') + # The Python prometheus_client text parser strips the `_total` suffix from + # counter metric family names (e.g. `foo_total` → family name `foo`). + # Mirror that behaviour here so existing metric maps that key on the + # suffix-free name continue to match when the Go parser is active. + if metric_type == 'counter' and name.endswith('_total'): + name = name[:-6] return Metric( - family['name'], - family.get('type', 'untyped'), + name, + metric_type, family.get('help', ''), samples, ) From aae0b6d59fae6a715e0b31cdecbfae780a63d8d3 Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Thu, 20 Aug 2026 13:04:19 -0400 Subject: [PATCH 06/10] update stub --- .../base/checks/openmetrics/v2/go_parser.py | 25 ++++++++++++---- .../base/stubs/datadog_agent.py | 29 ++++++++++++++++--- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py index 60a772fc12bc2..ea948b0026bbb 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -103,12 +103,25 @@ def _json_to_metric(family: dict) -> Metric: ] name = family['name'] metric_type = family.get('type', 'untyped') - # The Python prometheus_client text parser strips the `_total` suffix from - # counter metric family names (e.g. `foo_total` → family name `foo`). - # Mirror that behaviour here so existing metric maps that key on the - # suffix-free name continue to match when the Go parser is active. - if metric_type == 'counter' and name.endswith('_total'): - name = name[:-6] + raw_samples = family.get('samples', ()) + if metric_type == 'counter': + if name.endswith('_total'): + # Standard Prometheus convention: `# TYPE foo_total counter`. + # The Python prometheus_client parser strips `_total` from the + # family name (e.g. `foo_total` → `foo`). Mirror that so existing + # metric maps that key on the suffix-free name continue to match. + name = name[:-6] + else: + # Non-standard but common: `# TYPE foo counter` with the actual + # sample named `foo_total`. The Python parser fails to associate + # the `_total` sample with the declared counter family and instead + # yields it as a *separate* family of type ``unknown`` whose name + # IS `foo_total`. Metric maps written against the Python parser + # therefore key on `foo_total`, not `foo`. Reproduce that + # behaviour here so those maps continue to work. + total_name = name + '_total' + if any(s.get('name') == total_name for s in raw_samples): + name = total_name return Metric( name, metric_type, diff --git a/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py b/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py index 3af8be89b3056..36332c23bcbc1 100644 --- a/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py +++ b/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py @@ -221,10 +221,19 @@ def feed_prometheus_parser(self, parser_id, chunk): last_boundary = -1 for i in range(len(lines) - 1, 0, -1): if lines[i].startswith('# HELP ') or lines[i].startswith('# TYPE '): - # Check that there is at least one sample line before this boundary. - has_sample = any(line and not line.startswith('#') for line in lines[:i]) + # Walk backward through consecutive meta-lines (# HELP / # TYPE) so + # that the entire block for one family stays together in the buffer. + # Without this, a fixture with # TYPE before # HELP would split the + # pair — # TYPE lands in `complete` while # HELP + samples land in + # the buffer, causing the family to be parsed without its type + # declaration and treated as untyped. + j = i + while j > 0 and (lines[j - 1].startswith('# HELP ') or lines[j - 1].startswith('# TYPE ')): + j -= 1 + # Check that there is at least one sample line before this block. + has_sample = any(line and not line.startswith('#') for line in lines[:j]) if has_sample: - last_boundary = i + last_boundary = j break if last_boundary <= 0: @@ -284,9 +293,21 @@ def _parse_prometheus_text(text, content_type): sample['exemplar'] = exemplar samples.append(sample) if samples: + # The real Go parser preserves the TYPE-line name verbatim + # (e.g. "foo_total" from "# TYPE foo_total counter"), whereas + # prometheus_client strips "_total" from counter family names + # (returning "foo"). Restore the suffix here so the JSON + # fed to _json_to_metric matches what the real Go parser + # produces, letting _json_to_metric's Pattern-1 stripping + # apply consistently in both unit-test and E2E contexts. + family_name = metric.name + if metric.type == 'counter': + first_sample_name = samples[0]['name'] + if first_sample_name == family_name + '_total': + family_name = first_sample_name families.append( { - 'name': metric.name, + 'name': family_name, 'type': metric.type, 'help': metric.documentation, 'samples': samples, From 47934a63bbc527f8a9cbb635564259c1675952c3 Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Thu, 20 Aug 2026 16:35:19 -0400 Subject: [PATCH 07/10] fix --- .../base/checks/openmetrics/v2/go_parser.py | 35 +++++++++++-------- .../base/stubs/datadog_agent.py | 15 ++++---- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py index ea948b0026bbb..ad68d71f7a435 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -90,7 +90,7 @@ def _decode_value(v: float | str) -> float: return v -def _json_to_metric(family: dict) -> Metric: +def _json_to_metric(family: dict, is_openmetrics: bool = False) -> Metric: samples = [ Sample( s['name'], @@ -104,21 +104,23 @@ def _json_to_metric(family: dict) -> Metric: name = family['name'] metric_type = family.get('type', 'untyped') raw_samples = family.get('samples', ()) - if metric_type == 'counter': + # The Python OpenMetrics parser keeps the TYPE-line name as-is (no + # ``_total`` stripping), and the Go OpenMetrics parser does the same, + # so no normalization is needed for OpenMetrics. The ``_total`` + # stripping below only applies to the Prometheus text format, where + # the Python parser's ``build_metric`` strips it automatically. + if not is_openmetrics and metric_type == 'counter': if name.endswith('_total'): - # Standard Prometheus convention: `# TYPE foo_total counter`. - # The Python prometheus_client parser strips `_total` from the - # family name (e.g. `foo_total` → `foo`). Mirror that so existing - # metric maps that key on the suffix-free name continue to match. + # Standard Prometheus convention: ``# TYPE foo_total counter``. + # Strip ``_total`` so existing metric maps that key on the + # suffix-free name continue to match. name = name[:-6] else: - # Non-standard but common: `# TYPE foo counter` with the actual - # sample named `foo_total`. The Python parser fails to associate - # the `_total` sample with the declared counter family and instead - # yields it as a *separate* family of type ``unknown`` whose name - # IS `foo_total`. Metric maps written against the Python parser - # therefore key on `foo_total`, not `foo`. Reproduce that - # behaviour here so those maps continue to work. + # Non-standard but common: ``# TYPE foo counter`` with the actual + # sample named ``foo_total``. The Python parser yields this as a + # separate ``unknown`` family named ``foo_total``; metric maps + # written against that parser therefore key on ``foo_total``. + # Reproduce that here so those maps continue to work. total_name = name + '_total' if any(s.get('name') == total_name for s in raw_samples): name = total_name @@ -142,18 +144,21 @@ def parse_with_go_parser(content_type: str, line_streamer: Iterator[str]) -> Ite any complete metric families parsed so far, and ``finish_prometheus_parser`` flushes remaining data and releases the handle. """ + media_type = content_type.split(';')[0] if content_type else '' + is_openmetrics = media_type == 'application/openmetrics-text' + parser_id = datadog_agent.new_prometheus_parser(content_type) try: for chunk in batched_lines(line_streamer, target_size=128): families_json = datadog_agent.feed_prometheus_parser(parser_id, chunk) if families_json: for family in json.loads(families_json): - yield _json_to_metric(family) + yield _json_to_metric(family, is_openmetrics=is_openmetrics) remaining_json = datadog_agent.finish_prometheus_parser(parser_id) if remaining_json: for family in json.loads(remaining_json): - yield _json_to_metric(family) + yield _json_to_metric(family, is_openmetrics=is_openmetrics) except GeneratorExit: # Generator was closed before finishing; clean up the Go-side parser. try: diff --git a/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py b/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py index 36332c23bcbc1..205916cce908b 100644 --- a/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py +++ b/datadog_checks_base/datadog_checks/base/stubs/datadog_agent.py @@ -293,15 +293,14 @@ def _parse_prometheus_text(text, content_type): sample['exemplar'] = exemplar samples.append(sample) if samples: - # The real Go parser preserves the TYPE-line name verbatim - # (e.g. "foo_total" from "# TYPE foo_total counter"), whereas - # prometheus_client strips "_total" from counter family names - # (returning "foo"). Restore the suffix here so the JSON - # fed to _json_to_metric matches what the real Go parser - # produces, letting _json_to_metric's Pattern-1 stripping - # apply consistently in both unit-test and E2E contexts. + # The real Go parser preserves the TYPE-line name verbatim. + # For Prometheus format, the Python parser strips "_total" + # from counter family names (returning "foo" for + # "# TYPE foo_total counter"); restore it here so the JSON + # matches the Go parser output. For OpenMetrics format, + # neither parser strips the name, so no adjustment is needed. family_name = metric.name - if metric.type == 'counter': + if not DatadogAgentStub._is_openmetrics(content_type) and metric.type == 'counter': first_sample_name = samples[0]['name'] if first_sample_name == family_name + '_total': family_name = first_sample_name From bb4a6e38af8358f8279c586a8a3a9605d30320a5 Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Fri, 21 Aug 2026 13:55:07 -0400 Subject: [PATCH 08/10] fix --- .../base/checks/openmetrics/v2/go_parser.py | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py index ad68d71f7a435..22630d8779bfd 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -91,39 +91,35 @@ def _decode_value(v: float | str) -> float: def _json_to_metric(family: dict, is_openmetrics: bool = False) -> Metric: - samples = [ - Sample( - s['name'], - s.get('labels') or {}, - _decode_value(s['value']), - s.get('timestamp'), - s.get('exemplar'), - ) - for s in family.get('samples', ()) - ] name = family['name'] metric_type = family.get('type', 'untyped') raw_samples = family.get('samples', ()) - # The Python OpenMetrics parser keeps the TYPE-line name as-is (no - # ``_total`` stripping), and the Go OpenMetrics parser does the same, - # so no normalization is needed for OpenMetrics. The ``_total`` - # stripping below only applies to the Prometheus text format, where - # the Python parser's ``build_metric`` strips it automatically. + + # The Python prometheus_client parser adds ``_total`` to counter sample + # names and strips it from the family name. The Go parser preserves + # both as-is. Normalize here so downstream code (which was written + # against the Python parser's output) sees consistent names. + add_total_to_samples = False if not is_openmetrics and metric_type == 'counter': + add_total_to_samples = True if name.endswith('_total'): - # Standard Prometheus convention: ``# TYPE foo_total counter``. - # Strip ``_total`` so existing metric maps that key on the - # suffix-free name continue to match. name = name[:-6] else: - # Non-standard but common: ``# TYPE foo counter`` with the actual - # sample named ``foo_total``. The Python parser yields this as a - # separate ``unknown`` family named ``foo_total``; metric maps - # written against that parser therefore key on ``foo_total``. - # Reproduce that here so those maps continue to work. total_name = name + '_total' if any(s.get('name') == total_name for s in raw_samples): name = total_name + + samples = [ + Sample( + s['name'] if (not add_total_to_samples or s['name'].endswith('_total')) else s['name'] + '_total', + s.get('labels') or {}, + _decode_value(s['value']), + s.get('timestamp'), + s.get('exemplar'), + ) + for s in raw_samples + ] + return Metric( name, metric_type, From 6bb83de07d31a2d9f92bea506dc36cc30be83836 Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Fri, 21 Aug 2026 15:37:42 -0400 Subject: [PATCH 09/10] fix --- .../base/checks/openmetrics/v2/go_parser.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py index 22630d8779bfd..bd7e17c2f643e 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -95,13 +95,13 @@ def _json_to_metric(family: dict, is_openmetrics: bool = False) -> Metric: metric_type = family.get('type', 'untyped') raw_samples = family.get('samples', ()) - # The Python prometheus_client parser adds ``_total`` to counter sample - # names and strips it from the family name. The Go parser preserves - # both as-is. Normalize here so downstream code (which was written - # against the Python parser's output) sees consistent names. - add_total_to_samples = False + # The Python prometheus_client parser adds ``_total`` to the main counter + # sample and strips it from the family name. Non-standard suffixes + # (``_last``, ``_min``, ``_max``, etc.) become separate "unknown" families. + # The Go parser groups everything under one typed family. Normalize here + # so downstream code sees consistent names. + original_name = name if not is_openmetrics and metric_type == 'counter': - add_total_to_samples = True if name.endswith('_total'): name = name[:-6] else: @@ -109,9 +109,17 @@ def _json_to_metric(family: dict, is_openmetrics: bool = False) -> Metric: if any(s.get('name') == total_name for s in raw_samples): name = total_name + def _sample_name(raw_name): + # Only add ``_total`` to the sample whose name matches the TYPE-line + # family name exactly — that is the standard counter sample. + # Non-standard samples (``_last``, ``_min``, etc.) are left as-is. + if not is_openmetrics and metric_type == 'counter' and raw_name == original_name and not raw_name.endswith('_total'): + return raw_name + '_total' + return raw_name + samples = [ Sample( - s['name'] if (not add_total_to_samples or s['name'].endswith('_total')) else s['name'] + '_total', + _sample_name(s['name']), s.get('labels') or {}, _decode_value(s['value']), s.get('timestamp'), From 0057eac97172ce07e9da048833ce36aaf11d9e7a Mon Sep 17 00:00:00 2001 From: sarah-witt Date: Fri, 21 Aug 2026 16:42:15 -0400 Subject: [PATCH 10/10] fix counters --- .../base/checks/openmetrics/v2/go_parser.py | 123 ++++++++++++------ 1 file changed, 82 insertions(+), 41 deletions(-) diff --git a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py index bd7e17c2f643e..3d97edfc8171c 100644 --- a/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py +++ b/datadog_checks_base/datadog_checks/base/checks/openmetrics/v2/go_parser.py @@ -90,50 +90,91 @@ def _decode_value(v: float | str) -> float: return v -def _json_to_metric(family: dict, is_openmetrics: bool = False) -> Metric: +def _json_to_metrics(family: dict, is_openmetrics: bool = False) -> Iterator[Metric]: + """Convert a Go parser JSON family dict into one or more Metric objects. + + For Prometheus-format counters the Python ``prometheus_client`` parser + only keeps samples whose name matches a recognised counter suffix + (``_total``, ``_created``, or the bare family name) inside the counter + family. Non-standard suffixes (``_last``, ``_min``, ``_max``, ``_mean``, + ``_stddev``, …) are emitted as separate ``unknown``-type families. + + The Go parser groups *all* samples between consecutive TYPE directives + into one typed family, so we split them here to match the Python + behaviour that downstream code relies on. + """ name = family['name'] metric_type = family.get('type', 'untyped') raw_samples = family.get('samples', ()) + help_text = family.get('help', '') - # The Python prometheus_client parser adds ``_total`` to the main counter - # sample and strips it from the family name. Non-standard suffixes - # (``_last``, ``_min``, ``_max``, etc.) become separate "unknown" families. - # The Go parser groups everything under one typed family. Normalize here - # so downstream code sees consistent names. - original_name = name if not is_openmetrics and metric_type == 'counter': - if name.endswith('_total'): - name = name[:-6] - else: - total_name = name + '_total' - if any(s.get('name') == total_name for s in raw_samples): - name = total_name - - def _sample_name(raw_name): - # Only add ``_total`` to the sample whose name matches the TYPE-line - # family name exactly — that is the standard counter sample. - # Non-standard samples (``_last``, ``_min``, etc.) are left as-is. - if not is_openmetrics and metric_type == 'counter' and raw_name == original_name and not raw_name.endswith('_total'): - return raw_name + '_total' - return raw_name - - samples = [ - Sample( - _sample_name(s['name']), - s.get('labels') or {}, - _decode_value(s['value']), - s.get('timestamp'), - s.get('exemplar'), - ) - for s in raw_samples - ] - - return Metric( - name, - metric_type, - family.get('help', ''), - samples, - ) + # --- split standard / non-standard counter samples --------------- + standard_raw: list[dict] = [] + nonstandard_by_name: dict[str, list[dict]] = {} + + for s in raw_samples: + sname = s['name'] + if sname == name or sname == name + '_total' or sname == name + '_created': + standard_raw.append(s) + else: + nonstandard_by_name.setdefault(sname, []).append(s) + + # --- emit the counter family with standard samples only ---------- + if standard_raw: + original_name = name + if name.endswith('_total'): + name = name[:-6] + else: + total_name = name + '_total' + if any(s.get('name') == total_name for s in standard_raw): + name = total_name + + samples = [] + for s in standard_raw: + sname = s['name'] + # Add _total to the bare-name sample (Python behaviour). + if sname == original_name and not sname.endswith('_total'): + sname = sname + '_total' + samples.append(Sample( + sname, + s.get('labels') or {}, + _decode_value(s['value']), + s.get('timestamp'), + s.get('exemplar'), + )) + + yield Metric(name, 'counter', help_text, samples) + + # --- emit unknown families for non-standard samples -------------- + for ns_name, ns_raw in nonstandard_by_name.items(): + yield Metric( + ns_name, + 'unknown', + '', + [ + Sample( + ns_name, + s.get('labels') or {}, + _decode_value(s['value']), + s.get('timestamp'), + s.get('exemplar'), + ) + for s in ns_raw + ], + ) + else: + samples = [ + Sample( + s['name'], + s.get('labels') or {}, + _decode_value(s['value']), + s.get('timestamp'), + s.get('exemplar'), + ) + for s in raw_samples + ] + yield Metric(name, metric_type, help_text, samples) def parse_with_go_parser(content_type: str, line_streamer: Iterator[str]) -> Iterator[Metric]: @@ -157,12 +198,12 @@ def parse_with_go_parser(content_type: str, line_streamer: Iterator[str]) -> Ite families_json = datadog_agent.feed_prometheus_parser(parser_id, chunk) if families_json: for family in json.loads(families_json): - yield _json_to_metric(family, is_openmetrics=is_openmetrics) + yield from _json_to_metrics(family, is_openmetrics=is_openmetrics) remaining_json = datadog_agent.finish_prometheus_parser(parser_id) if remaining_json: for family in json.loads(remaining_json): - yield _json_to_metric(family, is_openmetrics=is_openmetrics) + yield from _json_to_metrics(family, is_openmetrics=is_openmetrics) except GeneratorExit: # Generator was closed before finishing; clean up the Go-side parser. try: