exposition.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. #!/usr/bin/env python
  2. from io import StringIO
  3. from sys import maxunicode
  4. from typing import Callable
  5. from ..utils import floatToGoString, parse_version
  6. from ..validation import (
  7. _is_valid_legacy_labelname, _is_valid_legacy_metric_name,
  8. )
  9. CONTENT_TYPE_LATEST = 'application/openmetrics-text; version=1.0.0; charset=utf-8'
  10. """Content type of the latest OpenMetrics 1.0 text format"""
  11. CONTENT_TYPE_LATEST_2_0 = 'application/openmetrics-text; version=2.0.0; charset=utf-8'
  12. """Content type of the OpenMetrics 2.0 text format"""
  13. ESCAPING_HEADER_TAG = 'escaping'
  14. ALLOWUTF8 = 'allow-utf-8'
  15. UNDERSCORES = 'underscores'
  16. DOTS = 'dots'
  17. VALUES = 'values'
  18. def _is_valid_exemplar_metric(metric, sample):
  19. if metric.type == 'counter' and sample.name.endswith('_total'):
  20. return True
  21. if metric.type in ('gaugehistogram') and sample.name.endswith('_bucket'):
  22. return True
  23. if metric.type in ('histogram') and sample.name.endswith('_bucket') or sample.name == metric.name:
  24. return True
  25. return False
  26. def _compose_exemplar_string(metric, sample, exemplar):
  27. """Constructs an exemplar string."""
  28. if not _is_valid_exemplar_metric(metric, sample):
  29. raise ValueError(f"Metric {metric.name} has exemplars, but is not a histogram bucket or counter")
  30. labels = '{{{0}}}'.format(','.join(
  31. ['{}="{}"'.format(
  32. k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))
  33. for k, v in sorted(exemplar.labels.items())]))
  34. if exemplar.timestamp is not None:
  35. exemplarstr = ' # {} {} {}'.format(
  36. labels,
  37. floatToGoString(exemplar.value),
  38. exemplar.timestamp,
  39. )
  40. else:
  41. exemplarstr = ' # {} {}'.format(
  42. labels,
  43. floatToGoString(exemplar.value),
  44. )
  45. return exemplarstr
  46. def generate_latest(registry, escaping=UNDERSCORES, version="1.0.0"):
  47. '''Returns the metrics from the registry in latest text format as a string.'''
  48. output = []
  49. for metric in registry.collect():
  50. try:
  51. mname = metric.name
  52. output.append('# HELP {} {}\n'.format(
  53. escape_metric_name(mname, escaping), _escape(metric.documentation, ALLOWUTF8, _is_legacy_labelname_rune)))
  54. output.append(f'# TYPE {escape_metric_name(mname, escaping)} {metric.type}\n')
  55. if metric.unit:
  56. output.append(f'# UNIT {escape_metric_name(mname, escaping)} {metric.unit}\n')
  57. for s in metric.samples:
  58. if escaping == ALLOWUTF8 and not _is_valid_legacy_metric_name(s.name):
  59. labelstr = escape_metric_name(s.name, escaping)
  60. if s.labels:
  61. labelstr += ','
  62. else:
  63. labelstr = ''
  64. if s.labels:
  65. items = sorted(s.labels.items())
  66. # Label values always support UTF-8
  67. labelstr += ','.join(
  68. ['{}="{}"'.format(
  69. escape_label_name(k, escaping), _escape(v, ALLOWUTF8, _is_legacy_labelname_rune))
  70. for k, v in items])
  71. if labelstr:
  72. labelstr = "{" + labelstr + "}"
  73. if s.exemplar:
  74. exemplarstr = _compose_exemplar_string(metric, s, s.exemplar)
  75. else:
  76. exemplarstr = ''
  77. timestamp = ''
  78. if s.timestamp is not None:
  79. timestamp = f' {s.timestamp}'
  80. # Skip native histogram samples entirely if version < 2.0.0
  81. if s.native_histogram and parse_version(version) < (2, 0, 0):
  82. continue
  83. native_histogram = ''
  84. negative_spans = ''
  85. negative_deltas = ''
  86. positive_spans = ''
  87. positive_deltas = ''
  88. if s.native_histogram:
  89. # Initialize basic nh template
  90. nh_sample_template = '{{count:{},sum:{},schema:{},zero_threshold:{},zero_count:{}'
  91. args = [
  92. s.native_histogram.count_value,
  93. s.native_histogram.sum_value,
  94. s.native_histogram.schema,
  95. s.native_histogram.zero_threshold,
  96. s.native_histogram.zero_count,
  97. ]
  98. # If there are neg spans, append them and the neg deltas to the template and args
  99. if s.native_histogram.neg_spans:
  100. negative_spans = ','.join([f'{ns[0]}:{ns[1]}' for ns in s.native_histogram.neg_spans])
  101. negative_deltas = ','.join(str(nd) for nd in s.native_histogram.neg_deltas)
  102. nh_sample_template += ',negative_spans:[{}]'
  103. args.append(negative_spans)
  104. nh_sample_template += ',negative_deltas:[{}]'
  105. args.append(negative_deltas)
  106. # If there are pos spans, append them and the pos spans to the template and args
  107. if s.native_histogram.pos_spans:
  108. positive_spans = ','.join([f'{ps[0]}:{ps[1]}' for ps in s.native_histogram.pos_spans])
  109. positive_deltas = ','.join(f'{pd}' for pd in s.native_histogram.pos_deltas)
  110. nh_sample_template += ',positive_spans:[{}]'
  111. args.append(positive_spans)
  112. nh_sample_template += ',positive_deltas:[{}]'
  113. args.append(positive_deltas)
  114. # Add closing brace
  115. nh_sample_template += '}}'
  116. # Format the template with the args
  117. native_histogram = nh_sample_template.format(*args)
  118. if s.native_histogram.nh_exemplars:
  119. for nh_ex in s.native_histogram.nh_exemplars:
  120. nh_exemplarstr = _compose_exemplar_string(metric, s, nh_ex)
  121. exemplarstr += nh_exemplarstr
  122. value = ''
  123. if s.native_histogram:
  124. value = native_histogram
  125. elif s.value is not None:
  126. value = floatToGoString(s.value)
  127. if (escaping != ALLOWUTF8) or _is_valid_legacy_metric_name(s.name):
  128. output.append('{}{} {}{}{}\n'.format(
  129. _escape(s.name, escaping, _is_legacy_labelname_rune),
  130. labelstr,
  131. value,
  132. timestamp,
  133. exemplarstr
  134. ))
  135. else:
  136. output.append('{} {}{}{}\n'.format(
  137. labelstr,
  138. value,
  139. timestamp,
  140. exemplarstr
  141. ))
  142. except Exception as exception:
  143. exception.args = (exception.args or ('',)) + (metric,)
  144. raise
  145. output.append('# EOF\n')
  146. return ''.join(output).encode('utf-8')
  147. def escape_metric_name(s: str, escaping: str = UNDERSCORES) -> str:
  148. """Escapes the metric name and puts it in quotes iff the name does not
  149. conform to the legacy Prometheus character set.
  150. """
  151. if len(s) == 0:
  152. return s
  153. if escaping == ALLOWUTF8:
  154. if not _is_valid_legacy_metric_name(s):
  155. return '"{}"'.format(_escape(s, escaping, _is_legacy_metric_rune))
  156. return _escape(s, escaping, _is_legacy_metric_rune)
  157. elif escaping == UNDERSCORES:
  158. if _is_valid_legacy_metric_name(s):
  159. return s
  160. return _escape(s, escaping, _is_legacy_metric_rune)
  161. elif escaping == DOTS:
  162. return _escape(s, escaping, _is_legacy_metric_rune)
  163. elif escaping == VALUES:
  164. if _is_valid_legacy_metric_name(s):
  165. return s
  166. return _escape(s, escaping, _is_legacy_metric_rune)
  167. return s
  168. def escape_label_name(s: str, escaping: str = UNDERSCORES) -> str:
  169. """Escapes the label name and puts it in quotes iff the name does not
  170. conform to the legacy Prometheus character set.
  171. """
  172. if len(s) == 0:
  173. return s
  174. if escaping == ALLOWUTF8:
  175. if not _is_valid_legacy_labelname(s):
  176. return '"{}"'.format(_escape(s, escaping, _is_legacy_labelname_rune))
  177. return _escape(s, escaping, _is_legacy_labelname_rune)
  178. elif escaping == UNDERSCORES:
  179. if _is_valid_legacy_labelname(s):
  180. return s
  181. return _escape(s, escaping, _is_legacy_labelname_rune)
  182. elif escaping == DOTS:
  183. return _escape(s, escaping, _is_legacy_labelname_rune)
  184. elif escaping == VALUES:
  185. if _is_valid_legacy_labelname(s):
  186. return s
  187. return _escape(s, escaping, _is_legacy_labelname_rune)
  188. return s
  189. def _escape(s: str, escaping: str, valid_rune_fn: Callable[[str, int], bool]) -> str:
  190. """Performs backslash escaping on backslash, newline, and double-quote characters.
  191. valid_rune_fn takes the input character and its index in the containing string."""
  192. if escaping == ALLOWUTF8:
  193. return s.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')
  194. elif escaping == UNDERSCORES:
  195. escaped = StringIO()
  196. for i, b in enumerate(s):
  197. if valid_rune_fn(b, i):
  198. escaped.write(b)
  199. else:
  200. escaped.write('_')
  201. return escaped.getvalue()
  202. elif escaping == DOTS:
  203. escaped = StringIO()
  204. for i, b in enumerate(s):
  205. if b == '_':
  206. escaped.write('__')
  207. elif b == '.':
  208. escaped.write('_dot_')
  209. elif valid_rune_fn(b, i):
  210. escaped.write(b)
  211. else:
  212. escaped.write('__')
  213. return escaped.getvalue()
  214. elif escaping == VALUES:
  215. escaped = StringIO()
  216. escaped.write("U__")
  217. for i, b in enumerate(s):
  218. if b == '_':
  219. escaped.write("__")
  220. elif valid_rune_fn(b, i):
  221. escaped.write(b)
  222. elif not _is_valid_utf8(b):
  223. escaped.write("_FFFD_")
  224. else:
  225. escaped.write('_')
  226. escaped.write(format(ord(b), 'x'))
  227. escaped.write('_')
  228. return escaped.getvalue()
  229. return s
  230. def _is_legacy_metric_rune(b: str, i: int) -> bool:
  231. return _is_legacy_labelname_rune(b, i) or b == ':'
  232. def _is_legacy_labelname_rune(b: str, i: int) -> bool:
  233. if len(b) != 1:
  234. raise ValueError("Input 'b' must be a single character.")
  235. return (
  236. ('a' <= b <= 'z')
  237. or ('A' <= b <= 'Z')
  238. or (b == '_')
  239. or ('0' <= b <= '9' and i > 0)
  240. )
  241. _SURROGATE_MIN = 0xD800
  242. _SURROGATE_MAX = 0xDFFF
  243. def _is_valid_utf8(s: str) -> bool:
  244. if 0 <= ord(s) < _SURROGATE_MIN:
  245. return True
  246. if _SURROGATE_MAX < ord(s) <= maxunicode:
  247. return True
  248. return False