11import base64
22from contextlib import closing
3+ from functools import partial
34import gzip
45from http .server import BaseHTTPRequestHandler
56import os
1920
2021from .openmetrics import exposition as openmetrics
2122from .registry import CollectorRegistry , REGISTRY
22- from .utils import floatToGoString
23- from .validation import _is_valid_legacy_metric_name
23+ from .utils import floatToGoString , parse_version
2424
2525__all__ = (
2626 'CONTENT_TYPE_LATEST' ,
27+ 'CONTENT_TYPE_PLAIN_0_0_4' ,
28+ 'CONTENT_TYPE_PLAIN_1_0_0' ,
2729 'delete_from_gateway' ,
2830 'generate_latest' ,
2931 'instance_ip_grouping_key' ,
3739 'write_to_textfile' ,
3840)
3941
40- CONTENT_TYPE_LATEST = 'text/plain; version=0.0.4; charset=utf-8'
41- """Content type of the latest text format"""
42+ CONTENT_TYPE_PLAIN_0_0_4 = 'text/plain; version=0.0.4; charset=utf-8'
43+ """Content type of the compatibility format"""
44+
45+ CONTENT_TYPE_PLAIN_1_0_0 = 'text/plain; version=1.0.0; charset=utf-8'
46+ """Content type of the latest format"""
47+
48+ CONTENT_TYPE_LATEST = CONTENT_TYPE_PLAIN_1_0_0
4249
4350
4451class _PrometheusRedirectHandler (HTTPRedirectHandler ):
@@ -245,29 +252,38 @@ class TmpServer(ThreadingWSGIServer):
245252start_http_server = start_wsgi_server
246253
247254
248- def generate_latest (registry : CollectorRegistry = REGISTRY ) -> bytes :
249- """Returns the metrics from the registry in latest text format as a string."""
255+ def generate_latest (registry : CollectorRegistry = REGISTRY , escaping : str = openmetrics .UNDERSCORES ) -> bytes :
256+ """
257+ Generates the exposition format using the basic Prometheus text format.
258+
259+ Params:
260+ registry: CollectorRegistry to export data from.
261+ escaping: Escaping scheme used for metric and label names.
262+
263+ Returns: UTF-8 encoded string containing the metrics in text format.
264+ """
250265
251266 def sample_line (samples ):
252267 if samples .labels :
253268 labelstr = '{0}' .format (',' .join (
269+ # Label values always support UTF-8
254270 ['{}="{}"' .format (
255- openmetrics .escape_label_name (k ), openmetrics ._escape (v ))
271+ openmetrics .escape_label_name (k , escaping ), openmetrics ._escape (v , openmetrics . ALLOWUTF8 , False ))
256272 for k , v in sorted (samples .labels .items ())]))
257273 else :
258274 labelstr = ''
259275 timestamp = ''
260276 if samples .timestamp is not None :
261277 # Convert to milliseconds.
262278 timestamp = f' { int (float (samples .timestamp ) * 1000 ):d} '
263- if _is_valid_legacy_metric_name (samples .name ):
279+ if escaping != openmetrics . ALLOWUTF8 or openmetrics . _is_valid_legacy_metric_name (samples .name ):
264280 if labelstr :
265281 labelstr = '{{{0}}}' .format (labelstr )
266- return f'{ samples .name } { labelstr } { floatToGoString (samples .value )} { timestamp } \n '
282+ return f'{ openmetrics . escape_metric_name ( samples .name , escaping ) } { labelstr } { floatToGoString (samples .value )} { timestamp } \n '
267283 maybe_comma = ''
268284 if labelstr :
269285 maybe_comma = ','
270- return f'{{{ openmetrics .escape_metric_name (samples .name )} { maybe_comma } { labelstr } }} { floatToGoString (samples .value )} { timestamp } \n '
286+ return f'{{{ openmetrics .escape_metric_name (samples .name , escaping )} { maybe_comma } { labelstr } }} { floatToGoString (samples .value )} { timestamp } \n '
271287
272288 output = []
273289 for metric in registry .collect ():
@@ -290,8 +306,8 @@ def sample_line(samples):
290306 mtype = 'untyped'
291307
292308 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 ' )
309+ openmetrics .escape_metric_name (mname , escaping ), metric .documentation .replace ('\\ ' , r'\\' ).replace ('\n ' , r'\n' )))
310+ output .append (f'# TYPE { openmetrics .escape_metric_name (mname , escaping )} { mtype } \n ' )
295311
296312 om_samples : Dict [str , List [str ]] = {}
297313 for s in metric .samples :
@@ -307,20 +323,79 @@ def sample_line(samples):
307323 raise
308324
309325 for suffix , lines in sorted (om_samples .items ()):
310- output .append ('# HELP {} {}\n ' .format (openmetrics .escape_metric_name (metric .name + suffix ),
326+ output .append ('# HELP {} {}\n ' .format (openmetrics .escape_metric_name (metric .name + suffix , escaping ),
311327 metric .documentation .replace ('\\ ' , r'\\' ).replace ('\n ' , r'\n' )))
312- output .append (f'# TYPE { openmetrics .escape_metric_name (metric .name + suffix )} gauge\n ' )
328+ output .append (f'# TYPE { openmetrics .escape_metric_name (metric .name + suffix , escaping )} gauge\n ' )
313329 output .extend (lines )
314330 return '' .join (output ).encode ('utf-8' )
315331
316332
317333def choose_encoder (accept_header : str ) -> Tuple [Callable [[CollectorRegistry ], bytes ], str ]:
334+ # Python client library accepts a narrower range of content-types than
335+ # Prometheus does.
318336 accept_header = accept_header or ''
337+ escaping = openmetrics .UNDERSCORES
319338 for accepted in accept_header .split (',' ):
320339 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
340+ toks = accepted .split (';' )
341+ version = _get_version (toks )
342+ escaping = _get_escaping (toks )
343+ # Only return an escaping header if we have a good version and
344+ # mimetype.
345+ if not version :
346+ return (partial (openmetrics .generate_latest , escaping = openmetrics .UNDERSCORES , version = "1.0.0" ), openmetrics .CONTENT_TYPE_LATEST )
347+ if version and parse_version (version ) >= (1 , 0 , 0 ):
348+ return (partial (openmetrics .generate_latest , escaping = escaping , version = version ),
349+ f'application/openmetrics-text; version={ version } ; charset=utf-8; escaping=' + str (escaping ))
350+ elif accepted .split (';' )[0 ].strip () == 'text/plain' :
351+ toks = accepted .split (';' )
352+ version = _get_version (toks )
353+ escaping = _get_escaping (toks )
354+ # Only return an escaping header if we have a good version and
355+ # mimetype.
356+ if version and parse_version (version ) >= (1 , 0 , 0 ):
357+ return (partial (generate_latest , escaping = escaping ),
358+ CONTENT_TYPE_LATEST + '; escaping=' + str (escaping ))
359+ return generate_latest , CONTENT_TYPE_PLAIN_0_0_4
360+
361+
362+ def _get_version (accept_header : List [str ]) -> str :
363+ """Return the version tag from the Accept header.
364+
365+ If no version is specified, returns empty string."""
366+
367+ for tok in accept_header :
368+ if '=' not in tok :
369+ continue
370+ key , value = tok .strip ().split ('=' , 1 )
371+ if key == 'version' :
372+ return value
373+ return ""
374+
375+
376+ def _get_escaping (accept_header : List [str ]) -> str :
377+ """Return the escaping scheme from the Accept header.
378+
379+ If no escaping scheme is specified or the scheme is not one of the allowed
380+ strings, defaults to UNDERSCORES."""
381+
382+ for tok in accept_header :
383+ if '=' not in tok :
384+ continue
385+ key , value = tok .strip ().split ('=' , 1 )
386+ if key != 'escaping' :
387+ continue
388+ if value == openmetrics .ALLOWUTF8 :
389+ return openmetrics .ALLOWUTF8
390+ elif value == openmetrics .UNDERSCORES :
391+ return openmetrics .UNDERSCORES
392+ elif value == openmetrics .DOTS :
393+ return openmetrics .DOTS
394+ elif value == openmetrics .VALUES :
395+ return openmetrics .VALUES
396+ else :
397+ return openmetrics .UNDERSCORES
398+ return openmetrics .UNDERSCORES
324399
325400
326401def gzip_accepted (accept_encoding_header : str ) -> bool :
@@ -369,15 +444,24 @@ def factory(cls, registry: CollectorRegistry) -> type:
369444 return MyMetricsHandler
370445
371446
372- def write_to_textfile (path : str , registry : CollectorRegistry ) -> None :
447+ def write_to_textfile (path : str , registry : CollectorRegistry , escaping : str = openmetrics . ALLOWUTF8 , tmpdir : Optional [ str ] = None ) -> None :
373448 """Write metrics to the given path.
374449
375450 This is intended for use with the Node exporter textfile collector.
376- The path must end in .prom for the textfile collector to process it."""
377- tmppath = f'{ path } .{ os .getpid ()} .{ threading .current_thread ().ident } '
451+ The path must end in .prom for the textfile collector to process it.
452+
453+ An optional tmpdir parameter can be set to determine where the
454+ metrics will be temporarily written to. If not set, it will be in
455+ the same directory as the .prom file. If provided, the path MUST be
456+ on the same filesystem."""
457+ if tmpdir is not None :
458+ filename = os .path .basename (path )
459+ tmppath = f'{ os .path .join (tmpdir , filename )} .{ os .getpid ()} .{ threading .current_thread ().ident } '
460+ else :
461+ tmppath = f'{ path } .{ os .getpid ()} .{ threading .current_thread ().ident } '
378462 try :
379463 with open (tmppath , 'wb' ) as f :
380- f .write (generate_latest (registry ))
464+ f .write (generate_latest (registry , escaping ))
381465
382466 # rename(2) is atomic but fails on Windows if the destination file exists
383467 if os .name == 'nt' :
@@ -645,7 +729,7 @@ def _use_gateway(
645729
646730 handler (
647731 url = url , method = method , timeout = timeout ,
648- headers = [('Content-Type' , CONTENT_TYPE_LATEST )], data = data ,
732+ headers = [('Content-Type' , CONTENT_TYPE_PLAIN_0_0_4 )], data = data ,
649733 )()
650734
651735
0 commit comments