Skip to content

Commit 7e3385b

Browse files
committed
Add support for int values, UNIT, more tests.
Signed-off-by: Brian Brazil <brian.brazil@robustperception.io>
1 parent b5db28b commit 7e3385b

File tree

4 files changed

+125
-17
lines changed

4 files changed

+125
-17
lines changed

prometheus_client/core.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
_unpack_double = struct.Struct(b'd').unpack_from
3737

3838
Sample = namedtuple('Sample', ['name', 'labels', 'value', 'timestamp', 'exemplar'])
39+
# Value can be an int or a float.
3940
# Timestamp and exemplar are optional.
4041
Sample.__new__.__defaults__ = (None, None)
4142

@@ -168,8 +169,12 @@ class Metric(object):
168169
and SummaryMetricFamily instead.
169170
'''
170171
def __init__(self, name, documentation, typ, unit=''):
172+
if not _METRIC_NAME_RE.match(name):
173+
raise ValueError('Invalid metric name: ' + name)
171174
self.name = name
172175
self.documentation = documentation
176+
if unit and not name.endswith("_" + unit):
177+
raise ValueError("Metric name not suffixed by unit: " + name)
173178
self.unit = unit
174179
if typ not in _METRIC_TYPES:
175180
raise ValueError('Invalid metric type: ' + typ)
@@ -190,8 +195,8 @@ def __eq__(self, other):
190195
self.samples == other.samples)
191196

192197
def __repr__(self):
193-
return "Metric(%s, %s, %s, %s)" % (self.name, self.documentation,
194-
self.type, self.samples)
198+
return "Metric(%s, %s, %s, %s, %s)" % (self.name, self.documentation,
199+
self.type, self.unit, self.samples)
195200

196201
class UntypedMetricFamily(Metric):
197202
'''A single untyped metric and its samples.
@@ -252,8 +257,8 @@ class GaugeMetricFamily(Metric):
252257
253258
For use by custom collectors.
254259
'''
255-
def __init__(self, name, documentation, value=None, labels=None):
256-
Metric.__init__(self, name, documentation, 'gauge')
260+
def __init__(self, name, documentation, value=None, labels=None, unit=''):
261+
Metric.__init__(self, name, documentation, 'gauge', unit)
257262
if labels is not None and value is not None:
258263
raise ValueError('Can only specify at most one of value and labels.')
259264
if labels is None:
@@ -277,8 +282,8 @@ class SummaryMetricFamily(Metric):
277282
278283
For use by custom collectors.
279284
'''
280-
def __init__(self, name, documentation, count_value=None, sum_value=None, labels=None):
281-
Metric.__init__(self, name, documentation, 'summary')
285+
def __init__(self, name, documentation, count_value=None, sum_value=None, labels=None, unit=''):
286+
Metric.__init__(self, name, documentation, 'summary', unit)
282287
if (sum_value is None) != (count_value is None):
283288
raise ValueError('count_value and sum_value must be provided together.')
284289
if labels is not None and count_value is not None:
@@ -306,8 +311,8 @@ class HistogramMetricFamily(Metric):
306311
307312
For use by custom collectors.
308313
'''
309-
def __init__(self, name, documentation, buckets=None, sum_value=None, labels=None):
310-
Metric.__init__(self, name, documentation, 'histogram')
314+
def __init__(self, name, documentation, buckets=None, sum_value=None, labels=None, unit=''):
315+
Metric.__init__(self, name, documentation, 'histogram', unit)
311316
if (sum_value is None) != (buckets is None):
312317
raise ValueError('buckets and sum_value must be provided together.')
313318
if labels is not None and buckets is not None:
@@ -339,8 +344,8 @@ class GaugeHistogramMetricFamily(Metric):
339344
340345
For use by custom collectors.
341346
'''
342-
def __init__(self, name, documentation, buckets=None, labels=None):
343-
Metric.__init__(self, name, documentation, 'gaugehistogram')
347+
def __init__(self, name, documentation, buckets=None, labels=None, unit=''):
348+
Metric.__init__(self, name, documentation, 'gaugehistogram', unit)
344349
if labels is not None and buckets is not None:
345350
raise ValueError('Can only specify at most one of buckets and labels.')
346351
if labels is None:

prometheus_client/openmetrics/exposition.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ def generate_latest(registry):
1212
output = []
1313
for metric in registry.collect():
1414
mname = metric.name
15-
output.append('# HELP {0} {1}'.format(
15+
output.append('# HELP {0} {1}\n'.format(
1616
mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')))
17-
output.append('\n# TYPE {0} {1}\n'.format(mname, metric.type))
17+
output.append('# TYPE {0} {1}\n'.format(mname, metric.type))
18+
if metric.unit:
19+
output.append('# UNIT {0} {1}\n'.format(mname, metric.unit))
1820
for s in metric.samples:
1921
if s.labels:
2022
labelstr = '{{{0}}}'.format(','.join(

prometheus_client/openmetrics/parser.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def _parse_sample(text):
8484
if char == '\\':
8585
state = 'labelvalueslash'
8686
elif char == '"':
87+
if not core._METRIC_LABEL_NAME_RE.match(''.join(labelname)):
88+
raise ValueError("Invalid line: " + text)
8789
labels[''.join(labelname)] = ''.join(labelvalue)
8890
labelname = []
8991
labelvalue = []
@@ -120,8 +122,13 @@ def _parse_sample(text):
120122
value.append(char)
121123
if not value:
122124
raise ValueError("Invalid line: " + text)
125+
val = None
126+
try:
127+
val = int(''.join(value))
128+
except ValueError:
129+
val = float(''.join(value))
123130

124-
return core.Sample(''.join(name), labels, float(''.join(value)))
131+
return core.Sample(''.join(name), labels, val)
125132

126133

127134
def text_fd_to_metric_families(fd):
@@ -141,11 +148,17 @@ def text_fd_to_metric_families(fd):
141148
allowed_names = []
142149
eof = False
143150

151+
seen_metrics = set()
144152
def build_metric(name, documentation, typ, unit, samples):
153+
if name in seen_metrics:
154+
raise ValueError("Duplicate metric: " + name)
155+
seen_metrics.add(name)
145156
metric = core.Metric(name, documentation, typ, unit)
146-
# TODO: chheck only hitogram buckets have exemplars.
157+
# TODO: check labelvalues are valid utf8
158+
# TODO: check only histogram buckets have exemplars.
159+
# TODO: Info and stateset can't have units
147160
# TODO: check samples are appropriately grouped and ordered
148-
# TODO: check metrics appear only once
161+
# TODO: check for metadata in middle of samples
149162
metric.samples = samples
150163
return metric
151164

@@ -160,7 +173,7 @@ def build_metric(name, documentation, typ, unit, samples):
160173
eof = True
161174
elif line.startswith('#'):
162175
parts = line.split(' ', 3)
163-
if len(parts) < 2:
176+
if len(parts) < 4:
164177
raise ValueError("Invalid line: " + line)
165178
if parts[1] == 'HELP':
166179
if parts[2] != name:
@@ -193,6 +206,16 @@ def build_metric(name, documentation, typ, unit, samples):
193206
'gaugehistogram': ['_bucket'],
194207
}.get(typ, [''])
195208
allowed_names = [name + n for n in allowed_names]
209+
elif parts[1] == 'UNIT':
210+
if parts[2] != name:
211+
if name != '':
212+
yield build_metric(name, documentation, typ, unit, samples)
213+
# New metric
214+
name = parts[2]
215+
typ = 'untyped'
216+
samples = []
217+
allowed_names = [parts[2]]
218+
unit = parts[3]
196219
else:
197220
raise ValueError("Invalid line: " + line)
198221
else:

tests/openmetrics/test_parser.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ def test_simple_counter(self):
3636
""")
3737
self.assertEqual([CounterMetricFamily("a", "help", value=1)], list(families))
3838

39+
def test_uint64_counter(self):
40+
families = text_string_to_metric_families("""# TYPE a counter
41+
# HELP a help
42+
a_total 9223372036854775808
43+
# EOF
44+
""")
45+
self.assertEqual([CounterMetricFamily("a", "help", value=9223372036854775808)], list(families))
46+
3947
def test_simple_gauge(self):
4048
families = text_string_to_metric_families("""# TYPE a gauge
4149
# HELP a help
@@ -44,6 +52,23 @@ def test_simple_gauge(self):
4452
""")
4553
self.assertEqual([GaugeMetricFamily("a", "help", value=1)], list(families))
4654

55+
def test_float_gauge(self):
56+
families = text_string_to_metric_families("""# TYPE a gauge
57+
# HELP a help
58+
a 1.2
59+
# EOF
60+
""")
61+
self.assertEqual([GaugeMetricFamily("a", "help", value=1.2)], list(families))
62+
63+
def test_unit_gauge(self):
64+
families = text_string_to_metric_families("""# TYPE a_seconds gauge
65+
# UNIT a_seconds seconds
66+
# HELP a_seconds help
67+
a_seconds 1
68+
# EOF
69+
""")
70+
self.assertEqual([GaugeMetricFamily("a_seconds", "help", value=1)], list(families))
71+
4772
def test_simple_summary(self):
4873
families = text_string_to_metric_families("""# TYPE a summary
4974
# HELP a help
@@ -152,9 +177,15 @@ def test_nan(self):
152177
families = text_string_to_metric_families("""a NaN
153178
# EOF
154179
""")
155-
# Can't use a simple comparison as nan != nan.
156180
self.assertTrue(math.isnan(list(families)[0].samples[0][2]))
157181

182+
def test_no_newline_after_eof(self):
183+
families = text_string_to_metric_families("""# TYPE a gauge
184+
# HELP a help
185+
a 1
186+
# EOF""")
187+
self.assertEqual([GaugeMetricFamily("a", "help", value=1)], list(families))
188+
158189
def test_empty_label(self):
159190
families = text_string_to_metric_families("""# TYPE a counter
160191
# HELP a help
@@ -289,6 +320,53 @@ def collect(self):
289320
registry.register(TextCollector())
290321
self.assertEqual(text.encode('utf-8'), generate_latest(registry))
291322

323+
def test_invalid_input(self):
324+
for case in [
325+
# No EOF.
326+
(''),
327+
# Text after EOF.
328+
('a 1\n# EOF\nblah'),
329+
('a 1\n# EOFblah'),
330+
# Missing or wrong quotes on label value.
331+
('a{a=1} 1\n# EOF\n'),
332+
('a{a="1} 1\n# EOF\n'),
333+
('a{a=\'1\'} 1\n# EOF\n'),
334+
# Missing or extra commas.
335+
('a{a="1"b="2"} 1\n# EOF\n'),
336+
('a{a="1",,b="2"} 1\n# EOF\n'),
337+
('a{a="1",b="2",} 1\n# EOF\n'),
338+
# Missing value.
339+
('a\n# EOF\n'),
340+
('a \n# EOF\n'),
341+
# Bad HELP.
342+
('# HELP\n# EOF\n'),
343+
('# HELP \n# EOF\n'),
344+
('# HELP a\n# EOF\n'),
345+
('# HELP a\t\n# EOF\n'),
346+
(' # HELP a meh\n# EOF\n'),
347+
# Bad TYPE.
348+
('# TYPE\n# EOF\n'),
349+
('# TYPE \n# EOF\n'),
350+
('# TYPE a\n# EOF\n'),
351+
('# TYPE a\t\n# EOF\n'),
352+
('# TYPE a meh\n# EOF\n'),
353+
('# TYPE a meh \n# EOF\n'),
354+
('# TYPE a gauge \n# EOF\n'),
355+
# Bad UNIT.
356+
('# UNIT\n# EOF\n'),
357+
('# UNIT \n# EOF\n'),
358+
('# UNIT a\n# EOF\n'),
359+
('# UNIT a\t\n# EOF\n'),
360+
('# UNIT a seconds\n# EOF\n'),
361+
('# UNIT a_seconds seconds \n# EOF\n'),
362+
# Bad metric names.
363+
('0a 1\n# EOF\n'),
364+
('a.b 1\n# EOF\n'),
365+
('a-b 1\n# EOF\n'),
366+
]:
367+
with self.assertRaises(ValueError):
368+
list(text_string_to_metric_families(case))
369+
292370

293371
if __name__ == '__main__':
294372
unittest.main()

0 commit comments

Comments
 (0)