1
1
import base64
2
2
from contextlib import closing
3
+ from functools import partial
3
4
import gzip
4
5
from http .server import BaseHTTPRequestHandler
5
6
import os
19
20
20
21
from .openmetrics import exposition as openmetrics
21
22
from .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
24
24
25
25
__all__ = (
26
26
'CONTENT_TYPE_LATEST' ,
27
+ 'CONTENT_TYPE_PLAIN_0_0_4' ,
28
+ 'CONTENT_TYPE_PLAIN_1_0_0' ,
27
29
'delete_from_gateway' ,
28
30
'generate_latest' ,
29
31
'instance_ip_grouping_key' ,
37
39
'write_to_textfile' ,
38
40
)
39
41
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
42
49
43
50
44
51
class _PrometheusRedirectHandler (HTTPRedirectHandler ):
@@ -245,29 +252,38 @@ class TmpServer(ThreadingWSGIServer):
245
252
start_http_server = start_wsgi_server
246
253
247
254
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
+ """
250
265
251
266
def sample_line (samples ):
252
267
if samples .labels :
253
268
labelstr = '{0}' .format (',' .join (
269
+ # Label values always support UTF-8
254
270
['{}="{}"' .format (
255
- openmetrics .escape_label_name (k ), openmetrics ._escape (v ))
271
+ openmetrics .escape_label_name (k , escaping ), openmetrics ._escape (v , openmetrics . ALLOWUTF8 , False ))
256
272
for k , v in sorted (samples .labels .items ())]))
257
273
else :
258
274
labelstr = ''
259
275
timestamp = ''
260
276
if samples .timestamp is not None :
261
277
# Convert to milliseconds.
262
278
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 ):
264
280
if labelstr :
265
281
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 '
267
283
maybe_comma = ''
268
284
if labelstr :
269
285
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 '
271
287
272
288
output = []
273
289
for metric in registry .collect ():
@@ -290,8 +306,8 @@ def sample_line(samples):
290
306
mtype = 'untyped'
291
307
292
308
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 ' )
295
311
296
312
om_samples : Dict [str , List [str ]] = {}
297
313
for s in metric .samples :
@@ -307,20 +323,79 @@ def sample_line(samples):
307
323
raise
308
324
309
325
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 ),
311
327
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 ' )
313
329
output .extend (lines )
314
330
return '' .join (output ).encode ('utf-8' )
315
331
316
332
317
333
def 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.
318
336
accept_header = accept_header or ''
337
+ escaping = openmetrics .UNDERSCORES
319
338
for accepted in accept_header .split (',' ):
320
339
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
324
399
325
400
326
401
def gzip_accepted (accept_encoding_header : str ) -> bool :
@@ -369,15 +444,24 @@ def factory(cls, registry: CollectorRegistry) -> type:
369
444
return MyMetricsHandler
370
445
371
446
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 :
373
448
"""Write metrics to the given path.
374
449
375
450
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 } '
378
462
try :
379
463
with open (tmppath , 'wb' ) as f :
380
- f .write (generate_latest (registry ))
464
+ f .write (generate_latest (registry , escaping ))
381
465
382
466
# rename(2) is atomic but fails on Windows if the destination file exists
383
467
if os .name == 'nt' :
@@ -645,7 +729,7 @@ def _use_gateway(
645
729
646
730
handler (
647
731
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 ,
649
733
)()
650
734
651
735
0 commit comments