stats: python: handle when pattern is not list in ls
[vpp.git] / src / vpp-api / python / vpp_papi / vpp_stats.py
1 #!/usr/bin/env python3
2 #
3 # Copyright (c) 2021 Cisco and/or its affiliates.
4 # Licensed under the Apache License, Version 2.0 (the "License");
5 # you may not use this file except in compliance with the License.
6 # You may obtain a copy of the License at:
7 #
8 #     http://www.apache.org/licenses/LICENSE-2.0
9 #
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS,
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
15 #
16
17 '''
18 This module implement Python access to the VPP statistics segment. It
19 accesses the data structures directly in shared memory.
20 VPP uses optimistic locking, so data structures may change underneath
21 us while we are reading. Data is copied out and it's important to
22 spend as little time as possible "holding the lock".
23
24 Counters are stored in VPP as a two dimensional array.
25 Index by thread and index (typically sw_if_index).
26 Simple counters count only packets, Combined counters count packets
27 and octets.
28
29 Counters can be accessed in either dimension.
30 stat['/if/rx'] - returns 2D lists
31 stat['/if/rx'][0] - returns counters for all interfaces for thread 0
32 stat['/if/rx'][0][1] - returns counter for interface 1 on thread 0
33 stat['/if/rx'][0][1]['packets'] - returns the packet counter
34                                   for interface 1 on thread 0
35 stat['/if/rx'][:, 1] - returns the counters for interface 1 on all threads
36 stat['/if/rx'][:, 1].packets() - returns the packet counters for
37                                  interface 1 on all threads
38 stat['/if/rx'][:, 1].sum_packets() - returns the sum of packet counters for
39                                      interface 1 on all threads
40 stat['/if/rx-miss'][:, 1].sum() - returns the sum of packet counters for
41                                   interface 1 on all threads for simple counters
42 '''
43
44 import os
45 import socket
46 import array
47 import mmap
48 from struct import Struct
49 import time
50 import unittest
51 import re
52
53 def recv_fd(sock):
54     '''Get file descriptor for memory map'''
55     fds = array.array("i")   # Array of ints
56     _, ancdata, _, _ = sock.recvmsg(0, socket.CMSG_LEN(4))
57     for cmsg_level, cmsg_type, cmsg_data in ancdata:
58         if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
59             fds.frombytes(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
60     return list(fds)[0]
61
62 VEC_LEN_FMT = Struct('I')
63 def get_vec_len(stats, vector_offset):
64     '''Equivalent to VPP vec_len()'''
65     return VEC_LEN_FMT.unpack_from(stats.statseg, vector_offset - 8)[0]
66
67 def get_string(stats, ptr):
68     '''Get a string from a VPP vector'''
69     namevector = ptr - stats.base
70     namevectorlen = get_vec_len(stats, namevector)
71     if namevector + namevectorlen >= stats.size:
72         raise ValueError('String overruns stats segment')
73     return stats.statseg[namevector:namevector+namevectorlen-1].decode('ascii')
74
75
76 class StatsVector:
77     '''A class representing a VPP vector'''
78
79     def __init__(self, stats, ptr, fmt):
80         self.vec_start = ptr - stats.base
81         self.vec_len = get_vec_len(stats, ptr - stats.base)
82         self.struct = Struct(fmt)
83         self.fmtlen = len(fmt)
84         self.elementsize = self.struct.size
85         self.statseg = stats.statseg
86         self.stats = stats
87
88         if self.vec_start + self.vec_len * self.elementsize >= stats.size:
89             raise ValueError('Vector overruns stats segment')
90
91     def __iter__(self):
92         with self.stats.lock:
93             return self.struct.iter_unpack(self.statseg[self.vec_start:self.vec_start +
94                                                         self.elementsize*self.vec_len])
95
96     def __getitem__(self, index):
97         if index > self.vec_len:
98             raise ValueError('Index beyond end of vector')
99         with self.stats.lock:
100             if self.fmtlen == 1:
101                 return self.struct.unpack_from(self.statseg, self.vec_start +
102                                                (index * self.elementsize))[0]
103             return self.struct.unpack_from(self.statseg, self.vec_start +
104                                            (index * self.elementsize))
105
106 class VPPStats():
107     '''Main class implementing Python access to the VPP statistics segment'''
108     # pylint: disable=too-many-instance-attributes
109     shared_headerfmt = Struct('QPQQPP')
110     default_socketname = '/run/vpp/stats.sock'
111
112     def __init__(self, socketname=default_socketname, timeout=10):
113         self.socketname = socketname
114         self.timeout = timeout
115         self.directory = {}
116         self.lock = StatsLock(self)
117         self.connected = False
118         self.size = 0
119         self.last_epoch = 0
120         self.error_vectors = 0
121         self.statseg = 0
122
123     def connect(self):
124         '''Connect to stats segment'''
125         if self.connected:
126             return
127         sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
128         sock.connect(self.socketname)
129
130         mfd = recv_fd(sock)
131         sock.close()
132
133         stat_result = os.fstat(mfd)
134         self.statseg = mmap.mmap(mfd, stat_result.st_size, mmap.PROT_READ, mmap.MAP_SHARED)
135         socket.close(mfd)
136
137         self.size = stat_result.st_size
138         if self.version != 2:
139             raise Exception('Incompatbile stat segment version {}'
140                             .format(self.version))
141
142         self.refresh()
143         self.connected = True
144
145     def disconnect(self):
146         '''Disconnect from stats segment'''
147         if self.connected:
148             self.statseg.close()
149             self.connected = False
150
151     @property
152     def version(self):
153         '''Get version of stats segment'''
154         return self.shared_headerfmt.unpack_from(self.statseg)[0]
155
156     @property
157     def base(self):
158         '''Get base pointer of stats segment'''
159         return self.shared_headerfmt.unpack_from(self.statseg)[1]
160
161     @property
162     def epoch(self):
163         '''Get current epoch value from stats segment'''
164         return self.shared_headerfmt.unpack_from(self.statseg)[2]
165
166     @property
167     def in_progress(self):
168         '''Get value of in_progress from stats segment'''
169         return self.shared_headerfmt.unpack_from(self.statseg)[3]
170
171     @property
172     def directory_vector(self):
173         '''Get pointer of directory vector'''
174         return self.shared_headerfmt.unpack_from(self.statseg)[4]
175
176     @property
177     def error_vector(self):
178         '''Get pointer of error vector'''
179         return self.shared_headerfmt.unpack_from(self.statseg)[5]
180
181     elementfmt = 'IQ128s'
182
183     def refresh(self):
184         '''Refresh directory vector cache (epoch changed)'''
185         directory = {}
186         with self.lock:
187             for direntry in StatsVector(self, self.directory_vector, self.elementfmt):
188                 path_raw = direntry[2].find(b'\x00')
189                 path = direntry[2][:path_raw].decode('ascii')
190                 directory[path] = StatsEntry(direntry[0], direntry[1])
191             self.last_epoch = self.epoch
192             self.directory = directory
193
194             # Cache the error index vectors
195             self.error_vectors = []
196             for threads in StatsVector(self, self.error_vector, 'P'):
197                 self.error_vectors.append(StatsVector(self, threads[0], 'Q'))
198
199     def __getitem__(self, item):
200         if not self.connected:
201             self.connect()
202         if self.last_epoch != self.epoch:
203             self.refresh()
204         with self.lock:
205             return self.directory[item].get_counter(self)
206
207     def __iter__(self):
208         return iter(self.directory.items())
209
210     def set_errors(self):
211         '''Return dictionary of error counters > 0'''
212         if not self.connected:
213             self.connect()
214
215         errors = {k:v for k, v in self.directory.items() if k.startswith("/err/")}
216         result = {}
217         with self.lock:
218             for k, entry in errors.items():
219                 total = 0
220                 i = entry.value
221                 for per_thread in self.error_vectors:
222                     total += per_thread[i]
223                 if total:
224                     result[k] = total
225         return result
226
227     def set_errors_str(self):
228         '''Return all errors counters > 0 pretty printed'''
229         error_string = ['ERRORS:']
230         error_counters = self.set_errors()
231         for k in sorted(error_counters):
232             error_string.append('{:<60}{:>10}'.format(k, error_counters[k]))
233         return '%s\n' % '\n'.join(error_string)
234
235     def get_counter(self, name):
236         '''Alternative call to __getitem__'''
237         return self.__getitem__(name)
238
239     def get_err_counter(self, name):
240         '''Return a single value (sum of all threads)'''
241         if not self.connected:
242             self.connect()
243         return sum(self.directory[name].get_counter(self))
244
245     def ls(self, patterns):
246         '''Returns list of counters matching pattern'''
247         # pylint: disable=invalid-name
248         if not self.connected:
249             self.connect()
250         if not isinstance(patterns, list):
251             patterns = [patterns]
252         regex = [re.compile(i) for i in patterns]
253         return [k for k, v in self.directory.items()
254                 if any(re.match(pattern, k) for pattern in regex)]
255
256     def dump(self, counters):
257         '''Given a list of counters return a dictionary of results'''
258         if not self.connected:
259             self.connect()
260         result = {}
261         for cnt in counters:
262             result[cnt] = self.__getitem__(cnt)
263         return result
264
265 class StatsLock():
266     '''Stat segment optimistic locking'''
267
268     def __init__(self, stats):
269         self.stats = stats
270         self.epoch = 0
271
272     def __enter__(self):
273         acquired = self.acquire(blocking=True)
274         assert acquired, "Lock wasn't acquired, but blocking=True"
275         return self
276
277     def __exit__(self, exc_type=None, exc_value=None, traceback=None):
278         self.release()
279
280     def acquire(self, blocking=True, timeout=-1):
281         '''Acquire the lock. Await in progress to go false. Record epoch.'''
282         self.epoch = self.stats.epoch
283         if timeout > 0:
284             start = time.monotonic()
285         while self.stats.in_progress:
286             if not blocking:
287                 time.sleep(0.01)
288                 if timeout > 0:
289                     if start + time.monotonic() > timeout:
290                         return False
291         return True
292
293     def release(self):
294         '''Check if data read while locked is valid'''
295         if self.stats.in_progress or self.stats.epoch != self.epoch:
296             raise IOError('Optimistic lock failed, retry')
297
298     def locked(self):
299         '''Not used'''
300
301
302 class StatsCombinedList(list):
303     '''Column slicing for Combined counters list'''
304
305     def __getitem__(self, item):
306         '''Supports partial numpy style 2d support. Slice by column [:,1]'''
307         if isinstance(item, int):
308             return list.__getitem__(self, item)
309         return CombinedList([row[item[1]] for row in self])
310
311 class CombinedList(list):
312     '''Combined Counters 2-dimensional by thread by index of packets/octets'''
313
314     def packets(self):
315         '''Return column (2nd dimension). Packets for all threads'''
316         return [pair[0] for pair in self]
317
318     def octets(self):
319         '''Return column (2nd dimension). Octets for all threads'''
320         return [pair[1] for pair in self]
321
322     def sum_packets(self):
323         '''Return column (2nd dimension). Sum of all packets for all threads'''
324         return sum(self.packets())
325
326     def sum_octets(self):
327         '''Return column (2nd dimension). Sum of all octets for all threads'''
328         return sum(self.octets())
329
330 class StatsTuple(tuple):
331     '''A Combined vector tuple (packets, octets)'''
332     def __init__(self, data):
333         self.dictionary = {'packets': data[0], 'bytes': data[1]}
334         super().__init__()
335
336     def __repr__(self):
337         return dict.__repr__(self.dictionary)
338
339     def __getitem__(self, item):
340         if isinstance(item, int):
341             return tuple.__getitem__(self, item)
342         if item == 'packets':
343             return tuple.__getitem__(self, 0)
344         return tuple.__getitem__(self, 1)
345
346 class StatsSimpleList(list):
347     '''Simple Counters 2-dimensional by thread by index of packets'''
348
349     def __getitem__(self, item):
350         '''Supports partial numpy style 2d support. Slice by column [:,1]'''
351         if isinstance(item, int):
352             return list.__getitem__(self, item)
353         return SimpleList([row[item[1]] for row in self])
354
355 class SimpleList(list):
356     '''Simple counter'''
357
358     def sum(self):
359         '''Sum the vector'''
360         return sum(self)
361
362 class StatsEntry():
363     '''An individual stats entry'''
364     # pylint: disable=unused-argument,no-self-use
365
366     def __init__(self, stattype, statvalue):
367         self.type = stattype
368         self.value = statvalue
369
370         if stattype == 1:
371             self.function = self.scalar
372         elif stattype == 2:
373             self.function = self.simple
374         elif stattype == 3:
375             self.function = self.combined
376         elif stattype == 4:
377             self.function = self.error
378         elif stattype == 5:
379             self.function = self.name
380         else:
381             self.function = self.illegal
382
383     def illegal(self, stats):
384         '''Invalid or unknown counter type'''
385         return None
386
387     def scalar(self, stats):
388         '''Scalar counter'''
389         return self.value
390
391     def simple(self, stats):
392         '''Simple counter'''
393         counter = StatsSimpleList()
394         for threads in StatsVector(stats, self.value, 'P'):
395             clist = [v[0] for v in StatsVector(stats, threads[0], 'Q')]
396             counter.append(clist)
397         return counter
398
399     def combined(self, stats):
400         '''Combined counter'''
401         counter = StatsCombinedList()
402         for threads in StatsVector(stats, self.value, 'P'):
403             clist = [StatsTuple(cnt) for cnt in StatsVector(stats, threads[0], 'QQ')]
404             counter.append(clist)
405         return counter
406
407     def error(self, stats):
408         '''Error counter'''
409         counter = SimpleList()
410         for clist in stats.error_vectors:
411             counter.append(clist[self.value])
412         return counter
413
414     def name(self, stats):
415         '''Name counter'''
416         counter = []
417         for name in StatsVector(stats, self.value, 'P'):
418             counter.append(get_string(stats, name[0]))
419         return counter
420
421     def get_counter(self, stats):
422         '''Return a list of counters'''
423         return self.function(stats)
424
425 class TestStats(unittest.TestCase):
426     '''Basic statseg tests'''
427
428     def setUp(self):
429         '''Connect to statseg'''
430         self.stat = VPPStats()
431         self.stat.connect()
432         self.profile = cProfile.Profile()
433         self.profile.enable()
434
435     def tearDown(self):
436         '''Disconnect from statseg'''
437         self.stat.disconnect()
438         profile = Stats(self.profile)
439         profile.strip_dirs()
440         profile.sort_stats('cumtime')
441         profile.print_stats()
442         print("\n--->>>")
443
444     def test_counters(self):
445         '''Test access to statseg'''
446
447         print('/err/abf-input-ip4/missed', self.stat['/err/abf-input-ip4/missed'])
448         print('/sys/heartbeat', self.stat['/sys/heartbeat'])
449         print('/if/names', self.stat['/if/names'])
450         print('/if/rx-miss', self.stat['/if/rx-miss'])
451         print('/if/rx-miss', self.stat['/if/rx-miss'][1])
452         print('/nat44-ed/out2in/slowpath/drops', self.stat['/nat44-ed/out2in/slowpath/drops'])
453         print('Set Errors', self.stat.set_errors())
454         with self.assertRaises(KeyError):
455             print('NO SUCH COUNTER', self.stat['foobar'])
456         print('/if/rx', self.stat.get_counter('/if/rx'))
457         print('/err/ethernet-input/no error',
458               self.stat.get_err_counter('/err/ethernet-input/no error'))
459
460     def test_column(self):
461         '''Test column slicing'''
462
463         print('/if/rx-miss', self.stat['/if/rx-miss'])
464         print('/if/rx', self.stat['/if/rx'])  # All interfaces for thread #1
465         print('/if/rx thread #1', self.stat['/if/rx'][0])  # All interfaces for thread #1
466         print('/if/rx thread #1, interface #1',
467               self.stat['/if/rx'][0][1])  # All interfaces for thread #1
468         print('/if/rx if_index #1', self.stat['/if/rx'][:, 1])
469         print('/if/rx if_index #1 packets', self.stat['/if/rx'][:, 1].packets())
470         print('/if/rx if_index #1 packets', self.stat['/if/rx'][:, 1].sum_packets())
471         print('/if/rx if_index #1 packets', self.stat['/if/rx'][:, 1].octets())
472         print('/if/rx-miss', self.stat['/if/rx-miss'])
473         print('/if/rx-miss if_index #1 packets', self.stat['/if/rx-miss'][:, 1].sum())
474         print('/if/rx if_index #1 packets', self.stat['/if/rx'][0][1]['packets'])
475
476     def test_error(self):
477         '''Test the error vector'''
478
479         print('/err/ethernet-input', self.stat['/err/ethernet-input/no error'])
480         print('/err/nat44-ei-ha/pkts-processed', self.stat['/err/nat44-ei-ha/pkts-processed'])
481         print('/err/ethernet-input', self.stat.get_err_counter('/err/ethernet-input/no error'))
482         print('/err/ethernet-input', self.stat['/err/ethernet-input/no error'].sum())
483
484     def test_nat44(self):
485         '''Test the nat counters'''
486
487         print('/nat44-ei/ha/del-event-recv', self.stat['/nat44-ei/ha/del-event-recv'])
488         print('/err/nat44-ei-ha/pkts-processed', self.stat['/err/nat44-ei-ha/pkts-processed'].sum())
489
490     def test_legacy(self):
491         '''Legacy interface'''
492         directory = self.stat.ls(["^/if", "/err/ip4-input", "/sys/node/ip4-input"])
493         data = self.stat.dump(directory)
494         print(data)
495         print('Looking up sys node')
496         directory = self.stat.ls(["^/sys/node"])
497         print('Dumping sys node')
498         data = self.stat.dump(directory)
499         print(data)
500         directory = self.stat.ls(["^/foobar"])
501         data = self.stat.dump(directory)
502         print(data)
503
504     def test_sys_nodes(self):
505         '''Test /sys/nodes'''
506         counters = self.stat.ls('^/sys/node')
507         print('COUNTERS:', counters)
508         print('/sys/node', self.stat.dump(counters))
509         print('/net/route/to', self.stat['/net/route/to'])
510
511 if __name__ == '__main__':
512     import cProfile
513     from pstats import Stats
514
515     unittest.main()