Skip to content

Commit b5db28b

Browse files
committed
Inirial work on OpenMetrics parser.
Signed-off-by: Brian Brazil <brian.brazil@robustperception.io>
1 parent b339d21 commit b5db28b

File tree

4 files changed

+517
-5
lines changed

4 files changed

+517
-5
lines changed

prometheus_client/core.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,10 @@ class Metric(object):
167167
Custom collectors should use GaugeMetricFamily, CounterMetricFamily
168168
and SummaryMetricFamily instead.
169169
'''
170-
def __init__(self, name, documentation, typ):
170+
def __init__(self, name, documentation, typ, unit=''):
171171
self.name = name
172172
self.documentation = documentation
173+
self.unit = unit
173174
if typ not in _METRIC_TYPES:
174175
raise ValueError('Invalid metric type: ' + typ)
175176
self.type = typ

prometheus_client/openmetrics/exposition.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ def generate_latest(registry):
1515
output.append('# HELP {0} {1}'.format(
1616
mname, metric.documentation.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"')))
1717
output.append('\n# TYPE {0} {1}\n'.format(mname, metric.type))
18-
for name, labels, value in metric.samples:
19-
if labels:
18+
for s in metric.samples:
19+
if s.labels:
2020
labelstr = '{{{0}}}'.format(','.join(
2121
['{0}="{1}"'.format(
2222
k, v.replace('\\', r'\\').replace('\n', r'\n').replace('"', r'\"'))
23-
for k, v in sorted(labels.items())]))
23+
for k, v in sorted(s.labels.items())]))
2424
else:
2525
labelstr = ''
26-
output.append('{0}{1} {2}\n'.format(name, labelstr, core._floatToGoString(value)))
26+
output.append('{0}{1} {2}\n'.format(s.name, labelstr, core._floatToGoString(s.value)))
2727
output.append('# EOF\n')
2828
return ''.join(output).encode('utf-8')
2929

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
#!/usr/bin/python
2+
3+
from __future__ import unicode_literals
4+
5+
try:
6+
import StringIO
7+
except ImportError:
8+
# Python 3
9+
import io as StringIO
10+
11+
from .. import core
12+
13+
14+
def text_string_to_metric_families(text):
15+
"""Parse Openmetrics text format from a unicode string.
16+
17+
See text_fd_to_metric_families.
18+
"""
19+
for metric_family in text_fd_to_metric_families(StringIO.StringIO(text)):
20+
yield metric_family
21+
22+
23+
def _unescape_help(text):
24+
result = []
25+
slash = False
26+
27+
for char in text:
28+
if slash:
29+
if char == '\\':
30+
result.append('\\')
31+
elif char == '"':
32+
result.append('"')
33+
elif char == 'n':
34+
result.append('\n')
35+
else:
36+
result.append('\\' + char)
37+
slash = False
38+
else:
39+
if char == '\\':
40+
slash = True
41+
else:
42+
result.append(char)
43+
44+
if slash:
45+
result.append('\\')
46+
47+
return ''.join(result)
48+
49+
50+
def _parse_sample(text):
51+
name = []
52+
labelname = []
53+
labelvalue = []
54+
value = []
55+
labels = {}
56+
57+
state = 'name'
58+
59+
for char in text:
60+
if state == 'name':
61+
if char == '{':
62+
state = 'startoflabelname'
63+
elif char == ' ':
64+
state = 'value'
65+
else:
66+
name.append(char)
67+
elif state == 'startoflabelname':
68+
if char == '}':
69+
state = 'endoflabels'
70+
else:
71+
state = 'labelname'
72+
labelname.append(char)
73+
elif state == 'labelname':
74+
if char == '=':
75+
state = 'labelvaluequote'
76+
else:
77+
labelname.append(char)
78+
elif state == 'labelvaluequote':
79+
if char == '"':
80+
state = 'labelvalue'
81+
else:
82+
raise ValueError("Invalid line: " + text)
83+
elif state == 'labelvalue':
84+
if char == '\\':
85+
state = 'labelvalueslash'
86+
elif char == '"':
87+
labels[''.join(labelname)] = ''.join(labelvalue)
88+
labelname = []
89+
labelvalue = []
90+
state = 'endoflabelvalue'
91+
else:
92+
labelvalue.append(char)
93+
elif state == 'endoflabelvalue':
94+
if char == ',':
95+
state = 'labelname'
96+
elif char == '}':
97+
state = 'endoflabels'
98+
else:
99+
raise ValueError("Invalid line: " + text)
100+
elif state == 'labelvalueslash':
101+
state = 'labelvalue'
102+
if char == '\\':
103+
labelvalue.append('\\')
104+
elif char == 'n':
105+
labelvalue.append('\n')
106+
elif char == '"':
107+
labelvalue.append('"')
108+
else:
109+
labelvalue.append('\\' + char)
110+
elif state == 'endoflabels':
111+
if char == ' ':
112+
state = 'value'
113+
else:
114+
raise ValueError("Invalid line: " + text)
115+
elif state == 'value':
116+
if char == ' ' or char == '#':
117+
# Timestamps and examplars are not supported, halt
118+
break
119+
else:
120+
value.append(char)
121+
if not value:
122+
raise ValueError("Invalid line: " + text)
123+
124+
return core.Sample(''.join(name), labels, float(''.join(value)))
125+
126+
127+
def text_fd_to_metric_families(fd):
128+
"""Parse Prometheus text format from a file descriptor.
129+
130+
This is a laxer parser than the main Go parser,
131+
so successful parsing does not imply that the parsed
132+
text meets the specification.
133+
134+
Yields core.Metric's.
135+
"""
136+
name = ''
137+
documentation = ''
138+
typ = 'untyped'
139+
unit = ''
140+
samples = []
141+
allowed_names = []
142+
eof = False
143+
144+
def build_metric(name, documentation, typ, unit, samples):
145+
metric = core.Metric(name, documentation, typ, unit)
146+
# TODO: chheck only hitogram buckets have exemplars.
147+
# TODO: check samples are appropriately grouped and ordered
148+
# TODO: check metrics appear only once
149+
metric.samples = samples
150+
return metric
151+
152+
for line in fd:
153+
if line[-1] == '\n':
154+
line = line[:-1]
155+
156+
if eof:
157+
raise ValueError("Received line after # EOF: " + line)
158+
159+
if line == '# EOF':
160+
eof = True
161+
elif line.startswith('#'):
162+
parts = line.split(' ', 3)
163+
if len(parts) < 2:
164+
raise ValueError("Invalid line: " + line)
165+
if parts[1] == 'HELP':
166+
if parts[2] != name:
167+
if name != '':
168+
yield build_metric(name, documentation, typ, unit, samples)
169+
# New metric
170+
name = parts[2]
171+
unit = ''
172+
typ = 'untyped'
173+
samples = []
174+
allowed_names = [parts[2]]
175+
if len(parts) == 4:
176+
documentation = _unescape_help(parts[3])
177+
elif len(parts) == 3:
178+
raise ValueError("Invalid line: " + line)
179+
elif parts[1] == 'TYPE':
180+
if parts[2] != name:
181+
if name != '':
182+
yield build_metric(name, documentation, typ, unit, samples)
183+
# New metric
184+
name = parts[2]
185+
documentation = ''
186+
unit = ''
187+
samples = []
188+
typ = parts[3]
189+
allowed_names = {
190+
'counter': ['_total', '_created'],
191+
'summary': ['_count', '_sum', '', '_created'],
192+
'histogram': ['_count', '_sum', '_bucket', 'created'],
193+
'gaugehistogram': ['_bucket'],
194+
}.get(typ, [''])
195+
allowed_names = [name + n for n in allowed_names]
196+
else:
197+
raise ValueError("Invalid line: " + line)
198+
else:
199+
sample = _parse_sample(line)
200+
if sample[0] not in allowed_names:
201+
if name != '':
202+
yield build_metric(name, documentation, typ, unit, samples)
203+
# Start an untyped metric.
204+
name = sample[0]
205+
documentation = ''
206+
unit = ''
207+
typ = 'untyped'
208+
samples = [sample]
209+
allowed_names = [sample[0]]
210+
else:
211+
samples.append(sample)
212+
213+
if name != '':
214+
yield build_metric(name, documentation, typ, unit, samples)
215+
216+
if not eof:
217+
raise ValueError("Missing # EOF at end")

0 commit comments

Comments
 (0)