Skip to content

Commit 831ed02

Browse files
committed
UTF-8 Content Negotiation
Part of #1013 Signed-off-by: Owen Williams <[email protected]>
1 parent d24220a commit 831ed02

File tree

12 files changed

+707
-78
lines changed

12 files changed

+707
-78
lines changed

prometheus_client/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
process_collector, registry,
66
)
77
from .exposition import (
8-
CONTENT_TYPE_LATEST, delete_from_gateway, generate_latest,
9-
instance_ip_grouping_key, make_asgi_app, make_wsgi_app, MetricsHandler,
10-
push_to_gateway, pushadd_to_gateway, start_http_server, start_wsgi_server,
8+
CONTENT_TYPE_LATEST, CONTENT_TYPE_PLAIN_0_0_4, CONTENT_TYPE_PLAIN_1_0_0,
9+
delete_from_gateway, generate_latest, instance_ip_grouping_key,
10+
make_asgi_app, make_wsgi_app, MetricsHandler, push_to_gateway,
11+
pushadd_to_gateway, start_http_server, start_wsgi_server,
1112
write_to_textfile,
1213
)
1314
from .gc_collector import GC_COLLECTOR, GCCollector
@@ -33,6 +34,8 @@
3334
'enable_created_metrics',
3435
'disable_created_metrics',
3536
'CONTENT_TYPE_LATEST',
37+
'CONTENT_TYPE_PLAIN_0_0_4',
38+
'CONTENT_TYPE_PLAIN_1_0_0',
3639
'generate_latest',
3740
'MetricsHandler',
3841
'make_wsgi_app',

prometheus_client/exposition.py

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
from contextlib import closing
3+
from functools import partial
34
import gzip
45
from http.server import BaseHTTPRequestHandler
56
import os
@@ -17,13 +18,16 @@
1718
)
1819
from wsgiref.simple_server import make_server, WSGIRequestHandler, WSGIServer
1920

21+
from packaging.version import Version
22+
2023
from .openmetrics import exposition as openmetrics
2124
from .registry import CollectorRegistry, REGISTRY
2225
from .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',
@@ -37,8 +41,13 @@
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

4453
class _PrometheusRedirectHandler(HTTPRedirectHandler):
@@ -245,29 +254,38 @@ class TmpServer(ThreadingWSGIServer):
245254
start_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

317335
def 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

326403
def 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

Comments
 (0)