6767from json import dumps
6868from logging import getLogger
6969from os import environ
70- from typing import Deque , Dict , Iterable , Sequence , Tuple , Union
70+ from typing import Deque , Dict , Iterable , Sequence , Tuple , Union , Optional
7171
7272from prometheus_client import start_http_server
7373from prometheus_client .core import (
107107 Sum ,
108108)
109109from opentelemetry .util .types import Attributes
110+ from opentelemetry .sdk .metrics ._internal import Exemplar
111+ from prometheus_client .samples import Exemplar as PrometheusExemplar
112+
110113
111114_logger = getLogger (__name__ )
112115
115118
116119
117120def _convert_buckets (
118- bucket_counts : Sequence [int ], explicit_bounds : Sequence [float ]
119- ) -> Sequence [Tuple [str , int ]]:
121+ bucket_counts : Sequence [int ], explicit_bounds : Sequence [float ], exemplars : Sequence [ Optional [ PrometheusExemplar ]] = None
122+ ) -> Sequence [Tuple [str , int , Optional [ Exemplar ] ]]:
120123 buckets = []
121124 total_count = 0
125+ previous_bound = float ('-inf' )
126+
122127 for upper_bound , count in zip (
123128 chain (explicit_bounds , ["+Inf" ]),
124129 bucket_counts ,
125130 ):
126131 total_count += count
127- buckets .append ((f"{ upper_bound } " , total_count ))
128-
132+ buckets .append ((f"{ upper_bound } " , total_count , None ))
133+
134+ # assigning exemplars to their corresponding values
135+ if exemplars :
136+ for i , (upper_bound , _ , _ ) in enumerate (buckets ):
137+ for exemplar in exemplars :
138+ if previous_bound <= exemplar .value < float (upper_bound ):
139+ # Assign the exemplar to the current bucket if it's the first valid one found
140+ _ , current_count , current_exemplar = buckets [i ]
141+ if current_exemplar is None : # Only assign if no exemplar has been assigned yet
142+ buckets [i ] = (upper_bound , current_count , exemplar )
143+ previous_bound = float (upper_bound )
129144 return buckets
130145
131146
@@ -251,6 +266,10 @@ def _translate_to_prometheus(
251266 for number_data_point in metric .data .data_points :
252267 label_keys = []
253268 label_values = []
269+ exemplars = [
270+ self ._convert_exemplar (ex ) for ex in number_data_point .exemplars
271+ ]
272+
254273
255274 for key , value in sorted (number_data_point .attributes .items ()):
256275 label_keys .append (sanitize_attribute (key ))
@@ -276,6 +295,7 @@ def _translate_to_prometheus(
276295 number_data_point .explicit_bounds
277296 ),
278297 "sum" : number_data_point .sum ,
298+ "exemplars" : exemplars ,
279299 }
280300 )
281301 else :
@@ -350,7 +370,7 @@ def _translate_to_prometheus(
350370 [pre_metric_family_id , HistogramMetricFamily .__name__ ]
351371 )
352372
353- if (
373+ if (
354374 metric_family_id
355375 not in metric_family_id_metric_family .keys ()
356376 ):
@@ -359,15 +379,15 @@ def _translate_to_prometheus(
359379 name = metric_name ,
360380 documentation = metric_description ,
361381 labels = label_keys ,
362- unit = metric_unit ,
382+ unit = metric_unit ,
363383 )
364384 )
365385 metric_family_id_metric_family [
366386 metric_family_id
367387 ].add_metric (
368388 labels = label_values ,
369389 buckets = _convert_buckets (
370- value ["bucket_counts" ], value ["explicit_bounds" ]
390+ value ["bucket_counts" ], value ["explicit_bounds" ], value [ "exemplars" ]
371391 ),
372392 sum_value = value ["sum" ],
373393 )
@@ -395,7 +415,29 @@ def _create_info_metric(
395415 info = InfoMetricFamily (name , description , labels = attributes )
396416 info .add_metric (labels = list (attributes .keys ()), value = attributes )
397417 return info
418+
419+ def _convert_exemplar (self , exemplar_data : Exemplar ) -> PrometheusExemplar :
420+ """
421+ Converts the SDK exemplar into a Prometheus Exemplar, including proper time conversion.
398422
423+ Parameters:
424+ - value (float): The value associated with the exemplar.
425+ - exemplar_data (ExemplarData): An OpenTelemetry exemplar data object containing attributes and timing information.
426+
427+ Returns:
428+ - Exemplar: A Prometheus Exemplar object with correct labeling and timing.
429+ """
430+ labels = {self ._sanitize_label (key ): str (value ) for key , value in exemplar_data .filtered_attributes .items ()}
431+
432+ # Add trace_id and span_id to labels only if they are valid and not None
433+ if exemplar_data .trace_id and exemplar_data .span_id :
434+ labels ['trace_id' ] = exemplar_data .trace_id
435+ labels ['span_id' ] = exemplar_data .span_id
436+
437+ # Convert time from nanoseconds to seconds
438+ timestamp_seconds = exemplar_data .time_unix_nano / 1e9
439+ prom_exemplar = PrometheusExemplar (labels , exemplar_data .value , timestamp_seconds )
440+ return prom_exemplar
399441
400442class _AutoPrometheusMetricReader (PrometheusMetricReader ):
401443 """Thin wrapper around PrometheusMetricReader used for the opentelemetry_metrics_exporter entry point.
0 commit comments