11import base64
22from contextlib import closing
3+ from functools import partial
34import gzip
45from http .server import BaseHTTPRequestHandler
56import os
1718)
1819from wsgiref .simple_server import make_server , WSGIRequestHandler , WSGIServer
1920
21+ from packaging .version import Version
22+
2023from .openmetrics import exposition as openmetrics
2124from .registry import CollectorRegistry , REGISTRY
2225from .utils import floatToGoString
23- from .validation import _is_valid_legacy_metric_name
2426
2527__all__ = (
2628 'CONTENT_TYPE_LATEST' ,
29+ 'CONTENT_TYPE_PLAIN_0_0_4' ,
30+ 'CONTENT_TYPE_PLAIN_1_0_0' ,
2731 'delete_from_gateway' ,
2832 'generate_latest' ,
2933 'instance_ip_grouping_key' ,
3741 'write_to_textfile' ,
3842)
3943
40- CONTENT_TYPE_LATEST = 'text/plain; version=0.0.4; charset=utf-8'
41- """Content type of the latest text format"""
44+ CONTENT_TYPE_PLAIN_0_0_4 = 'text/plain; version=0.0.4; charset=utf-8'
45+ """Content type of the compatibility format"""
46+
47+ CONTENT_TYPE_PLAIN_1_0_0 = 'text/plain; version=1.0.0; charset=utf-8'
48+ """Content type of the latest format"""
49+
50+ CONTENT_TYPE_LATEST = CONTENT_TYPE_PLAIN_1_0_0
4251
4352
4453class _PrometheusRedirectHandler (HTTPRedirectHandler ):
@@ -245,29 +254,38 @@ class TmpServer(ThreadingWSGIServer):
245254start_http_server = start_wsgi_server
246255
247256
248- def generate_latest (registry : CollectorRegistry = REGISTRY ) -> bytes :
249- """Returns the metrics from the registry in latest text format as a string."""
257+ def generate_latest (registry : CollectorRegistry = REGISTRY , escaping : str = openmetrics .UNDERSCORES ) -> bytes :
258+ """
259+ Generates the exposition format using the basic Prometheus text format.
260+
261+ Params:
262+ registry: CollectorRegistry to export data from.
263+ escaping: Escaping scheme used for metric and label names.
264+
265+ Returns: UTF-8 encoded string containing the metrics in text format.
266+ """
250267
251268 def sample_line (samples ):
252269 if samples .labels :
253270 labelstr = '{0}' .format (',' .join (
271+ # Label values always support UTF-8
254272 ['{}="{}"' .format (
255- openmetrics .escape_label_name (k ), openmetrics ._escape (v ))
273+ openmetrics .escape_label_name (k , escaping ), openmetrics ._escape (v , openmetrics . ALLOWUTF8 , False ))
256274 for k , v in sorted (samples .labels .items ())]))
257275 else :
258276 labelstr = ''
259277 timestamp = ''
260278 if samples .timestamp is not None :
261279 # Convert to milliseconds.
262280 timestamp = f' { int (float (samples .timestamp ) * 1000 ):d} '
263- if _is_valid_legacy_metric_name (samples .name ):
281+ if escaping != openmetrics . ALLOWUTF8 or openmetrics . _is_valid_legacy_metric_name (samples .name ):
264282 if labelstr :
265283 labelstr = '{{{0}}}' .format (labelstr )
266- return f'{ samples .name } { labelstr } { floatToGoString (samples .value )} { timestamp } \n '
284+ return f'{ openmetrics . escape_metric_name ( samples .name , escaping ) } { labelstr } { floatToGoString (samples .value )} { timestamp } \n '
267285 maybe_comma = ''
268286 if labelstr :
269287 maybe_comma = ','
270- return f'{{{ openmetrics .escape_metric_name (samples .name )} { maybe_comma } { labelstr } }} { floatToGoString (samples .value )} { timestamp } \n '
288+ return f'{{{ openmetrics .escape_metric_name (samples .name , escaping )} { maybe_comma } { labelstr } }} { floatToGoString (samples .value )} { timestamp } \n '
271289
272290 output = []
273291 for metric in registry .collect ():
@@ -290,8 +308,8 @@ def sample_line(samples):
290308 mtype = 'untyped'
291309
292310 output .append ('# HELP {} {}\n ' .format (
293- openmetrics .escape_metric_name (mname ), metric .documentation .replace ('\\ ' , r'\\' ).replace ('\n ' , r'\n' )))
294- output .append (f'# TYPE { openmetrics .escape_metric_name (mname )} { mtype } \n ' )
311+ openmetrics .escape_metric_name (mname , escaping ), metric .documentation .replace ('\\ ' , r'\\' ).replace ('\n ' , r'\n' )))
312+ output .append (f'# TYPE { openmetrics .escape_metric_name (mname , escaping )} { mtype } \n ' )
295313
296314 om_samples : Dict [str , List [str ]] = {}
297315 for s in metric .samples :
@@ -307,20 +325,79 @@ def sample_line(samples):
307325 raise
308326
309327 for suffix , lines in sorted (om_samples .items ()):
310- output .append ('# HELP {} {}\n ' .format (openmetrics .escape_metric_name (metric .name + suffix ),
328+ output .append ('# HELP {} {}\n ' .format (openmetrics .escape_metric_name (metric .name + suffix , escaping ),
311329 metric .documentation .replace ('\\ ' , r'\\' ).replace ('\n ' , r'\n' )))
312- output .append (f'# TYPE { openmetrics .escape_metric_name (metric .name + suffix )} gauge\n ' )
330+ output .append (f'# TYPE { openmetrics .escape_metric_name (metric .name + suffix , escaping )} gauge\n ' )
313331 output .extend (lines )
314332 return '' .join (output ).encode ('utf-8' )
315333
316334
317335def choose_encoder (accept_header : str ) -> Tuple [Callable [[CollectorRegistry ], bytes ], str ]:
336+ # Python client library accepts a narrower range of content-types than
337+ # Prometheus does.
318338 accept_header = accept_header or ''
339+ escaping = openmetrics .UNDERSCORES
319340 for accepted in accept_header .split (',' ):
320341 if accepted .split (';' )[0 ].strip () == 'application/openmetrics-text' :
321- return (openmetrics .generate_latest ,
322- openmetrics .CONTENT_TYPE_LATEST )
323- return generate_latest , CONTENT_TYPE_LATEST
342+ toks = accepted .split (';' )
343+ version = _get_version (toks )
344+ escaping = _get_escaping (toks )
345+ # Only return an escaping header if we have a good version and
346+ # mimetype.
347+ if not version :
348+ return (partial (openmetrics .generate_latest , escaping = openmetrics .UNDERSCORES ), openmetrics .CONTENT_TYPE_LATEST )
349+ if version and Version (version ) >= Version ('1.0.0' ):
350+ return (partial (openmetrics .generate_latest , escaping = escaping ),
351+ openmetrics .CONTENT_TYPE_LATEST + '; escaping=' + str (escaping ))
352+ elif accepted .split (';' )[0 ].strip () == 'text/plain' :
353+ toks = accepted .split (';' )
354+ version = _get_version (toks )
355+ escaping = _get_escaping (toks )
356+ # Only return an escaping header if we have a good version and
357+ # mimetype.
358+ if version and Version (version ) >= Version ('1.0.0' ):
359+ return (partial (generate_latest , escaping = escaping ),
360+ CONTENT_TYPE_LATEST + '; escaping=' + str (escaping ))
361+ return generate_latest , CONTENT_TYPE_PLAIN_0_0_4
362+
363+
364+ def _get_version (accept_header : List [str ]) -> str :
365+ """Return the version tag from the Accept header.
366+
367+ If no version is specified, returns empty string."""
368+
369+ for tok in accept_header :
370+ if '=' not in tok :
371+ continue
372+ key , value = tok .strip ().split ('=' , 1 )
373+ if key == 'version' :
374+ return value
375+ return ""
376+
377+
378+ def _get_escaping (accept_header : List [str ]) -> str :
379+ """Return the escaping scheme from the Accept header.
380+
381+ If no escaping scheme is specified or the scheme is not one of the allowed
382+ strings, defaults to UNDERSCORES."""
383+
384+ for tok in accept_header :
385+ if '=' not in tok :
386+ continue
387+ key , value = tok .strip ().split ('=' , 1 )
388+ if key != 'escaping' :
389+ continue
390+ if value == openmetrics .ALLOWUTF8 :
391+ return openmetrics .ALLOWUTF8
392+ elif value == openmetrics .UNDERSCORES :
393+ return openmetrics .UNDERSCORES
394+ elif value == openmetrics .DOTS :
395+ return openmetrics .DOTS
396+ elif value == openmetrics .VALUES :
397+ return openmetrics .VALUES
398+ else :
399+ return openmetrics .UNDERSCORES
400+ return openmetrics .UNDERSCORES
324401
325402
326403def gzip_accepted (accept_encoding_header : str ) -> bool :
@@ -369,15 +446,15 @@ def factory(cls, registry: CollectorRegistry) -> type:
369446 return MyMetricsHandler
370447
371448
372- def write_to_textfile (path : str , registry : CollectorRegistry ) -> None :
449+ def write_to_textfile (path : str , registry : CollectorRegistry , escaping : str = openmetrics . ALLOWUTF8 ) -> None :
373450 """Write metrics to the given path.
374451
375452 This is intended for use with the Node exporter textfile collector.
376453 The path must end in .prom for the textfile collector to process it."""
377454 tmppath = f'{ path } .{ os .getpid ()} .{ threading .current_thread ().ident } '
378455 try :
379456 with open (tmppath , 'wb' ) as f :
380- f .write (generate_latest (registry ))
457+ f .write (generate_latest (registry , escaping ))
381458
382459 # rename(2) is atomic but fails on Windows if the destination file exists
383460 if os .name == 'nt' :
@@ -645,7 +722,7 @@ def _use_gateway(
645722
646723 handler (
647724 url = url , method = method , timeout = timeout ,
648- headers = [('Content-Type' , CONTENT_TYPE_LATEST )], data = data ,
725+ headers = [('Content-Type' , CONTENT_TYPE_PLAIN_0_0_4 )], data = data ,
649726 )()
650727
651728
0 commit comments