Coverage for anaconda_opentelemetry/metrics.py: 88%
111 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 20:51 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 20:51 +0000
1# -*- coding: utf-8 -*-
2# SPDX-FileCopyrightText: 2025 Anaconda, Inc
3# SPDX-License-Identifier: Apache-2.0
5# metrics.py
6"""
7Anaconda Telemetry - Metrics signal class.
8"""
10import logging, re
11from typing import Dict, Any
13from opentelemetry import metrics
14from opentelemetry.sdk.metrics import MeterProvider, Counter, UpDownCounter, Histogram, ObservableCounter, ObservableUpDownCounter, _Gauge
15from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader, ConsoleMetricExporter, AggregationTemporality
17from .common import _AnacondaCommon, MetricsNotInitialized
18from .config import Configuration as Config
19from .attributes import ResourceAttributes as Attributes
20from .exporter_shim import OTLPMetricExporterShim
21from .formatting import AttrDict
24class _AnacondaMetrics(_AnacondaCommon):
25 # Singleton instance (internal only); provide a single instance of the metrics class
26 _instance = None
28 _default_temporality: dict[type,AggregationTemporality] = {
29 Counter: AggregationTemporality.DELTA,
30 ObservableCounter: AggregationTemporality.DELTA,
31 Histogram: AggregationTemporality.CUMULATIVE,
32 UpDownCounter: AggregationTemporality.DELTA,
33 ObservableUpDownCounter: AggregationTemporality.CUMULATIVE,
34 _Gauge: AggregationTemporality.CUMULATIVE, # A gauge is a last-value metric; DELTA is meaningless for it.
35 }
37 _cumulative_temporality: dict[type,AggregationTemporality] = {
38 Counter: AggregationTemporality.CUMULATIVE,
39 ObservableCounter: AggregationTemporality.CUMULATIVE,
40 Histogram: AggregationTemporality.CUMULATIVE,
41 UpDownCounter: AggregationTemporality.CUMULATIVE,
42 ObservableUpDownCounter: AggregationTemporality.CUMULATIVE,
43 _Gauge: AggregationTemporality.CUMULATIVE,
44 }
46 _temporalityValue: dict[bool,str] = {
47 False: "DELTA",
48 True: "CUMULATIVE"
49 }
51 def __init__(self, config: Config, attributes: Attributes):
52 super().__init__(config, attributes)
54 self.metrics_endpoint = config._get_metrics_endpoint()
55 self.telemetry_export_interval_millis = config._get_metrics_export_interval_ms()
56 self.counter_objects: Dict[str, Any] = {}
57 self.up_down_counter_objects: Dict[str, Any] = {}
58 self.histogram_objects: Dict[str, Any] = {}
59 self.gauge_objects: Dict[str, Any] = {}
61 self.meter = self._setup_metrics(config)
62 self.create_dispatcher = {
63 'simple_counter': self.meter.create_counter,
64 'simple_up_down_counter': self.meter.create_up_down_counter,
65 'histogram': self.meter.create_histogram,
66 'gauge': self.meter.create_gauge
67 }
68 self.type_list = {
69 'simple_counter': self.counter_objects,
70 'simple_up_down_counter': self.up_down_counter_objects,
71 'histogram': self.histogram_objects,
72 'gauge': self.gauge_objects
73 }
75 def _setup_metrics(self, config: Config) -> metrics.Meter:
76 if self.use_console_exporters:
77 exporter = ConsoleMetricExporter(preferred_temporality=self._get_temporality())
78 else:
79 auth_token = config._get_auth_token_metrics()
80 headers: Dict[str, str] = {}
81 if auth_token is not None:
82 headers['authorization'] = f'Bearer {auth_token}'
83 if config._get_request_protocol_metrics() in ['grpc', 'grpcs']: # gRPC
84 from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter as OTLPMetricExportergRPC
85 insecure = not config._get_TLS_metrics()
86 exporter = OTLPMetricExporterShim(
87 OTLPMetricExportergRPC,
88 endpoint=self.metrics_endpoint,
89 insecure=insecure,
90 credentials=config._get_ca_cert_metrics() if not insecure else None,
91 headers=headers,
92 preferred_temporality=self._get_temporality()
93 )
94 else: # HTTP
95 from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter as OTLPMetricExporterHTTP
96 http_kwargs = self._build_http_exporter_kwargs(
97 'metrics', self.metrics_endpoint, headers,
98 preferred_temporality=self._get_temporality()
99 )
100 exporter = OTLPMetricExporterShim(
101 OTLPMetricExporterHTTP,
102 **http_kwargs
103 )
105 self.exporter = exporter
106 self.metric_reader = PeriodicExportingMetricReader(self.exporter, export_interval_millis=self.telemetry_export_interval_millis)
107 # Create and set meter provider
108 meter_provider = MeterProvider(
109 resource=self.resource,
110 metric_readers=[self.metric_reader],
111 shutdown_on_exit=self._shutdown_on_exit
112 )
113 self._provider = meter_provider
114 try:
115 metrics.set_meter_provider(meter_provider)
116 except Exception as e:
117 self.logger.warning(f"The metrics provider was previously set and will take precidence over this call.")
118 # Get meter for this service
119 return metrics.get_meter(self.service_name, self.service_version)
121 def _get_temporality(self) -> dict[type,AggregationTemporality]:
122 if self._config._get_use_cumulative_metrics() == True:
123 return _AnacondaMetrics._cumulative_temporality
124 return _AnacondaMetrics._default_temporality
126 def _check_for_metric(self, metric_name: str, metric_type: str) -> bool:
127 bucket_list = self.type_list.get(metric_type, None)
128 if bucket_list is None:
129 return False
130 return bucket_list.get(metric_name, None) is not None
132 def _get_or_create_metric(self, metric_name: str, metric_type: str = 'simple_up_down_counter', units: str = '#', description='No description.') -> Any:
133 bucket_list = self.type_list.get(metric_type, None)
134 if bucket_list is None:
135 raise MetricsNotInitialized(f"Metric type '{metric_type}' is unknown!")
136 metric = bucket_list.get(metric_name, None)
137 if metric is None:
138 if not re.fullmatch(r"^[A-Za-z][A-Za-z_0-9.]+$", metric_name):
139 self.logger.warning(f"Metric {metric_name} does not match valid regex: r\"^[A-Za-z][A-Za-z_0-9.]+$\"")
140 return None
141 create = self.create_dispatcher.get(metric_type, None)
142 if create is None:
143 self.logger.warning(f"Metric '{metric_name}' has an invalid type '{metric_type}'; cannot create metric.")
144 return None
145 metric = create(
146 metric_name,
147 unit=units,
148 description=description
149 )
150 if metric is None:
151 self.logger.error(f"Failed to create metric '{metric_name}'!")
152 bucket_list[metric_name] = metric
153 return metric
155 def record_histogram(self, metric_name, value, attributes: AttrDict={}) -> bool:
156 # Record a histogram metric with the given name and value.
157 metric = self._get_or_create_metric(metric_name, metric_type='histogram', units='#', description='Dynamically create histogram metric.')
158 if metric is None:
159 self.logger.error(f"Metric '{metric_name}' failed to be created.")
160 return False
161 metric.record(value, attributes)
162 return True
164 def set_gauge(self, metric_name, value, attributes: AttrDict={}) -> bool:
165 # Set a gauge metric with the given name to the given value; the last value set wins.
166 # Unlike the other instruments, OTel's Gauge.set() does no arithmetic on the value, so a
167 # non-numeric value is accepted here and only fails later during serialization -- which
168 # discards the whole export batch, not just this metric. Reject it up front instead.
169 if isinstance(value, bool) or not isinstance(value, (int, float)):
170 self.logger.error(f"Metric '{metric_name}' gauge value must be an int or float, not {type(value).__name__}.")
171 return False
172 metric = self._get_or_create_metric(metric_name, metric_type='gauge', units='#', description='Dynamically create gauge metric.')
173 if metric is None:
174 self.logger.error(f"Metric '{metric_name}' failed to be created.")
175 return False
176 metric.set(value, attributes)
177 return True
179 def increment_counter(self, counter_name, by=1, attributes: AttrDict={}) -> bool:
180 # Increment a counter with the given name by the 'by' parameter. abs(by) is used.
181 metric = None
182 if self._check_for_metric(metric_name=counter_name, metric_type='simple_counter'):
183 metric = self._get_or_create_metric(counter_name, metric_type='simple_counter')
184 if metric is None:
185 metric = self._get_or_create_metric(counter_name, metric_type='simple_up_down_counter')
186 if metric is None:
187 self.logger.error(f"Metric '{counter_name}' failed to be created.")
188 return False
189 metric.add(abs(by), attributes)
190 return True
192 def decrement_counter(self, counter_name, by=1, attributes:AttrDict={}) -> bool:
193 # Decrement a up down counter with the given name by the 'by' parameter. abs(by) is used.
194 metric = self._get_or_create_metric(counter_name)
195 if metric is None:
196 self.logger.error(f"Metric '{counter_name}' failed to be created.")
197 return False
198 metric.add(-abs(by), attributes)
199 return True