]> git.ipfire.org Git - collecty.git/blob - src/collecty/plugins/base.py
logging: Tidy up code and align with other projects
[collecty.git] / src / collecty / plugins / base.py
1 #!/usr/bin/python3
2 ###############################################################################
3 # #
4 # collecty - A system statistics collection daemon for IPFire #
5 # Copyright (C) 2012 IPFire development team #
6 # #
7 # This program is free software: you can redistribute it and/or modify #
8 # it under the terms of the GNU General Public License as published by #
9 # the Free Software Foundation, either version 3 of the License, or #
10 # (at your option) any later version. #
11 # #
12 # This program is distributed in the hope that it will be useful, #
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
15 # GNU General Public License for more details. #
16 # #
17 # You should have received a copy of the GNU General Public License #
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. #
19 # #
20 ###############################################################################
21
22 import datetime
23 import logging
24 import math
25 import os
26 import re
27 import rrdtool
28 import tempfile
29 import threading
30 import time
31 import unicodedata
32
33 from .. import locales
34 from .. import util
35 from ..constants import *
36 from ..i18n import _
37
38 DEF_MATCH = re.compile(r"C?DEF:([A-Za-z0-9_]+)=")
39
40 class Timer(object):
41 def __init__(self, timeout, heartbeat=1):
42 self.timeout = timeout
43 self.heartbeat = heartbeat
44
45 self.delay = 0
46
47 self.reset()
48
49 def reset(self, delay=0):
50 # Save start time.
51 self.start = time.time()
52
53 self.delay = delay
54
55 # Has this timer been killed?
56 self.killed = False
57
58 @property
59 def elapsed(self):
60 return time.time() - self.start - self.delay
61
62 def cancel(self):
63 self.killed = True
64
65 def wait(self):
66 while self.elapsed < self.timeout and not self.killed:
67 time.sleep(self.heartbeat)
68
69 return self.elapsed > self.timeout
70
71
72 class Environment(object):
73 """
74 Sets the correct environment for rrdtool to create
75 localised graphs and graphs in the correct timezone.
76 """
77 def __init__(self, timezone, locale):
78 # Build the new environment
79 self.new_environment = {
80 "TZ" : timezone or DEFAULT_TIMEZONE,
81 }
82
83 for k in ("LANG", "LC_ALL"):
84 self.new_environment[k] = locale or DEFAULT_LOCALE
85
86 def __enter__(self):
87 # Save the current environment
88 self.old_environment = {}
89 for k in self.new_environment:
90 self.old_environment[k] = os.environ.get(k, None)
91
92 # Apply the new one
93 os.environ.update(self.new_environment)
94
95 def __exit__(self, type, value, traceback):
96 # Roll back to the previous environment
97 for k, v in self.old_environment.items():
98 if v is None:
99 try:
100 del os.environ[k]
101 except KeyError:
102 pass
103 else:
104 os.environ[k] = v
105
106
107 class PluginRegistration(type):
108 plugins = {}
109
110 def __init__(plugin, name, bases, dict):
111 type.__init__(plugin, name, bases, dict)
112
113 # The main class from which is inherited is not registered
114 # as a plugin.
115 if name == "Plugin":
116 return
117
118 if not all((plugin.name, plugin.description)):
119 raise RuntimeError(_("Plugin is not properly configured: %s") % plugin)
120
121 PluginRegistration.plugins[plugin.name] = plugin
122
123
124 def get():
125 """
126 Returns a list with all automatically registered plugins.
127 """
128 return PluginRegistration.plugins.values()
129
130 class Plugin(object, metaclass=PluginRegistration):
131 # The name of this plugin.
132 name = None
133
134 # A description for this plugin.
135 description = None
136
137 # Templates which can be used to generate a graph out of
138 # the data from this data source.
139 templates = []
140
141 # The default interval for all plugins
142 interval = 60
143
144 def __init__(self, collecty, **kwargs):
145 self.collecty = collecty
146
147 # Check if this plugin was configured correctly.
148 assert self.name, "Name of the plugin is not set: %s" % self.name
149 assert self.description, "Description of the plugin is not set: %s" % self.description
150
151 # Initialize the logger.
152 self.log = logging.getLogger("collecty.plugins.%s" % self.name)
153
154 self.data = []
155
156 # Run some custom initialization.
157 self.init(**kwargs)
158
159 self.log.debug(_("Successfully initialized %s") % self.__class__.__name__)
160
161 @property
162 def path(self):
163 """
164 Returns the name of the sub directory in which all RRD files
165 for this plugin should be stored in.
166 """
167 return self.name
168
169 ### Basic methods
170
171 def init(self, **kwargs):
172 """
173 Do some custom initialization stuff here.
174 """
175 pass
176
177 def collect(self):
178 """
179 Gathers the statistical data, this plugin collects.
180 """
181 time_start = time.time()
182
183 # Run through all objects of this plugin and call the collect method.
184 for o in self.objects:
185 now = datetime.datetime.utcnow()
186 try:
187 result = o.collect()
188
189 result = self._format_result(result)
190 except:
191 self.log.warning(_("Unhandled exception in %s.collect()") % o, exc_info=True)
192 continue
193
194 if not result:
195 self.log.warning(_("Received empty result: %s") % o)
196 continue
197
198 self.log.debug(_("Collected %s: %s") % (o, result))
199
200 # Add the object to the write queue so that the data is written
201 # to the databases later.
202 self.collecty.write_queue.add(o, now, result)
203
204 # Returns the time this function took to complete.
205 delay = time.time() - time_start
206
207 # Log some warning when a collect method takes too long to return some data
208 if delay >= 60:
209 self.log.warning(_("A worker thread was stalled for %.4fs") % delay)
210
211 @staticmethod
212 def _format_result(result):
213 if not isinstance(result, tuple) and not isinstance(result, list):
214 return result
215
216 # Replace all Nones by UNKNOWN
217 s = []
218
219 for e in result:
220 if e is None:
221 e = "U"
222
223 s.append("%s" % e)
224
225 return ":".join(s)
226
227 def get_object(self, id):
228 for object in self.objects:
229 if not object.id == id:
230 continue
231
232 return object
233
234 def get_template(self, template_name, object_id, locale=None, timezone=None):
235 for template in self.templates:
236 if not template.name == template_name:
237 continue
238
239 return template(self, object_id, locale=locale, timezone=timezone)
240
241 def generate_graph(self, template_name, object_id="default",
242 timezone=None, locale=None, **kwargs):
243 template = self.get_template(template_name, object_id=object_id,
244 timezone=timezone, locale=locale)
245 if not template:
246 raise RuntimeError("Could not find template %s" % template_name)
247
248 time_start = time.time()
249
250 graph = template.generate_graph(**kwargs)
251
252 duration = time.time() - time_start
253 self.log.debug(_("Generated graph %s in %.1fms") \
254 % (template, duration * 1000))
255
256 return graph
257
258 def graph_info(self, template_name, object_id="default",
259 timezone=None, locale=None, **kwargs):
260 template = self.get_template(template_name, object_id=object_id,
261 timezone=timezone, locale=locale)
262 if not template:
263 raise RuntimeError("Could not find template %s" % template_name)
264
265 return template.graph_info()
266
267 def last_update(self, object_id="default"):
268 object = self.get_object(object_id)
269 if not object:
270 raise RuntimeError("Could not find object %s" % object_id)
271
272 return object.last_update()
273
274
275 class Object(object):
276 # The schema of the RRD database.
277 rrd_schema = None
278
279 # RRA properties.
280 rra_types = ("AVERAGE", "MIN", "MAX")
281 rra_timespans = (
282 ("1m", "10d"),
283 ("1h", "18M"),
284 ("1d", "5y"),
285 )
286
287 def __init__(self, plugin, *args, **kwargs):
288 self.plugin = plugin
289
290 # Indicates if this object has collected its data
291 self.collected = False
292
293 # Initialise this object
294 self.init(*args, **kwargs)
295
296 # Create the database file.
297 self.create()
298
299 def __repr__(self):
300 return "<%s>" % self.__class__.__name__
301
302 def __lt__(self, other):
303 return self.id < other.id
304
305 @property
306 def collecty(self):
307 return self.plugin.collecty
308
309 @property
310 def log(self):
311 return self.plugin.log
312
313 @property
314 def id(self):
315 """
316 Returns a UNIQUE identifier for this object. As this is incorporated
317 into the path of RRD file, it must only contain ASCII characters.
318 """
319 raise NotImplementedError
320
321 @property
322 def file(self):
323 """
324 The absolute path to the RRD file of this plugin.
325 """
326 filename = self._normalise_filename("%s.rrd" % self.id)
327
328 return os.path.join(DATABASE_DIR, self.plugin.path, filename)
329
330 @staticmethod
331 def _normalise_filename(filename):
332 # Convert the filename into ASCII characters only
333 filename = unicodedata.normalize("NFKC", filename)
334
335 # Replace any spaces by dashes
336 filename = filename.replace(" ", "-")
337
338 return filename
339
340 ### Basic methods
341
342 def init(self, *args, **kwargs):
343 """
344 Do some custom initialization stuff here.
345 """
346 pass
347
348 def create(self):
349 """
350 Creates an empty RRD file with the desired data structures.
351 """
352 # Skip if the file does already exist.
353 if os.path.exists(self.file):
354 return
355
356 dirname = os.path.dirname(self.file)
357 if not os.path.exists(dirname):
358 os.makedirs(dirname)
359
360 # Create argument list.
361 args = self.get_rrd_schema()
362
363 rrdtool.create(self.file, *args)
364
365 self.log.debug(_("Created RRD file %s.") % self.file)
366 for arg in args:
367 self.log.debug(" %s" % arg)
368
369 def info(self):
370 return rrdtool.info(self.file)
371
372 def last_update(self):
373 """
374 Returns a dictionary with the timestamp and
375 data set of the last database update.
376 """
377 return {
378 "dataset" : self.last_dataset,
379 "timestamp" : self.last_updated,
380 }
381
382 def _last_update(self):
383 return rrdtool.lastupdate(self.file)
384
385 @property
386 def last_updated(self):
387 """
388 Returns the timestamp when this database was last updated
389 """
390 lu = self._last_update()
391
392 if lu:
393 return lu.get("date")
394
395 @property
396 def last_dataset(self):
397 """
398 Returns the latest dataset in the database
399 """
400 lu = self._last_update()
401
402 if lu:
403 return lu.get("ds")
404
405 @property
406 def stepsize(self):
407 return self.plugin.interval
408
409 @property
410 def heartbeat(self):
411 return self.stepsize * 2
412
413 def get_rrd_schema(self):
414 schema = [
415 "--step", "%s" % self.stepsize,
416 ]
417 for line in self.rrd_schema:
418 if line.startswith("DS:"):
419 try:
420 (prefix, name, type, lower_limit, upper_limit) = line.split(":")
421
422 line = ":".join((
423 prefix,
424 name,
425 type,
426 "%s" % self.heartbeat,
427 lower_limit,
428 upper_limit
429 ))
430 except ValueError:
431 pass
432
433 schema.append(line)
434
435 xff = 0.1
436
437 for steps, rows in self.rra_timespans:
438 for type in self.rra_types:
439 schema.append("RRA:%s:%s:%s:%s" % (type, xff, steps, rows))
440
441 return schema
442
443 @property
444 def rrd_schema_names(self):
445 ret = []
446
447 for line in self.rrd_schema:
448 (prefix, name, type, lower_limit, upper_limit) = line.split(":")
449 ret.append(name)
450
451 return ret
452
453 def make_rrd_defs(self, prefix=None):
454 defs = []
455
456 for name in self.rrd_schema_names:
457 if prefix:
458 p = "%s_%s" % (prefix, name)
459 else:
460 p = name
461
462 defs += [
463 "DEF:%s=%s:%s:AVERAGE" % (p, self.file, name),
464 ]
465
466 return defs
467
468 def get_stddev(self, interval=None):
469 args = self.make_rrd_defs()
470
471 # Add the correct interval
472 args += ["--start", util.make_interval(interval)]
473
474 for name in self.rrd_schema_names:
475 args += [
476 "VDEF:%s_stddev=%s,STDEV" % (name, name),
477 "PRINT:%s_stddev:%%lf" % name,
478 ]
479
480 x, y, vals = rrdtool.graph("/dev/null", *args)
481 return dict(zip(self.rrd_schema_names, vals))
482
483 def execute(self):
484 if self.collected:
485 raise RuntimeError("This object has already collected its data")
486
487 self.collected = True
488 self.now = datetime.datetime.utcnow()
489
490 # Call the collect
491 result = self.collect()
492
493 def commit(self):
494 """
495 Will commit the collected data to the database.
496 """
497 # Make sure that the RRD database has been created
498 self.create()
499
500 # Write everything to disk that is in the write queue
501 self.collecty.write_queue.commit_file(self.file)
502
503
504 class GraphTemplate(object):
505 # A unique name to identify this graph template.
506 name = None
507
508 # Headline of the graph image
509 graph_title = None
510
511 # Vertical label of the graph
512 graph_vertical_label = None
513
514 # Limits
515 lower_limit = None
516 upper_limit = None
517
518 # Instructions how to create the graph.
519 rrd_graph = None
520
521 # Extra arguments passed to rrdgraph.
522 rrd_graph_args = []
523
524 # Default dimensions for this graph
525 height = GRAPH_DEFAULT_HEIGHT
526 width = GRAPH_DEFAULT_WIDTH
527
528 def __init__(self, plugin, object_id, locale=None, timezone=None):
529 self.plugin = plugin
530
531 # Save localisation parameters
532 self.locale = locales.get(locale)
533 self.timezone = timezone
534
535 # Get all required RRD objects
536 self.object_id = object_id
537
538 # Get the main object
539 self.objects = self.get_objects(self.object_id)
540 self.objects.sort()
541
542 def __repr__(self):
543 return "<%s>" % self.__class__.__name__
544
545 @property
546 def collecty(self):
547 return self.plugin.collecty
548
549 @property
550 def log(self):
551 return self.plugin.log
552
553 @property
554 def object(self):
555 """
556 Shortcut to the main object
557 """
558 if len(self.objects) == 1:
559 return self.objects[0]
560
561 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
562 width=None, height=None, with_title=True, thumbnail=False):
563 args = [e for e in GRAPH_DEFAULT_ARGUMENTS]
564
565 # Set the default dimensions
566 default_height, default_width = GRAPH_DEFAULT_HEIGHT, GRAPH_DEFAULT_WIDTH
567
568 # A thumbnail doesn't have a legend and other labels
569 if thumbnail:
570 args.append("--only-graph")
571
572 default_height = THUMBNAIL_DEFAULT_HEIGHT
573 default_width = THUMBNAIL_DEFAULT_WIDTH
574
575 args += [
576 "--imgformat", format,
577 "--height", "%s" % (height or default_height),
578 "--width", "%s" % (width or default_width),
579 ]
580
581 args += self.rrd_graph_args
582
583 # Graph title
584 if with_title and self.graph_title:
585 args += ["--title", self.graph_title]
586
587 # Vertical label
588 if self.graph_vertical_label:
589 args += ["--vertical-label", self.graph_vertical_label]
590
591 if self.lower_limit is not None or self.upper_limit is not None:
592 # Force to honour the set limits
593 args.append("--rigid")
594
595 if self.lower_limit is not None:
596 args += ["--lower-limit", self.lower_limit]
597
598 if self.upper_limit is not None:
599 args += ["--upper-limit", self.upper_limit]
600
601 # Add interval
602 args += ["--start", util.make_interval(interval)]
603
604 return args
605
606 def _add_defs(self):
607 use_prefix = len(self.objects) >= 2
608
609 args = []
610 for object in self.objects:
611 if use_prefix:
612 args += object.make_rrd_defs(object.id)
613 else:
614 args += object.make_rrd_defs()
615
616 return args
617
618 def _add_vdefs(self, args):
619 ret = []
620
621 for arg in args:
622 ret.append(arg)
623
624 # Search for all DEFs and CDEFs
625 m = re.match(DEF_MATCH, "%s" % arg)
626 if m:
627 name = m.group(1)
628
629 # Add the VDEFs for minimum, maximum, etc. values
630 ret += [
631 "VDEF:%s_cur=%s,LAST" % (name, name),
632 "VDEF:%s_avg=%s,AVERAGE" % (name, name),
633 "VDEF:%s_max=%s,MAXIMUM" % (name, name),
634 "VDEF:%s_min=%s,MINIMUM" % (name, name),
635 ]
636
637 return ret
638
639 def get_objects(self, *args, **kwargs):
640 object = self.plugin.get_object(*args, **kwargs)
641
642 if object:
643 return [object,]
644
645 return []
646
647 def generate_graph(self, interval=None, **kwargs):
648 assert self.objects, "Cannot render graph without any objects"
649
650 # Make sure that all collected data is in the database
651 # to get a recent graph image
652 for object in self.objects:
653 object.commit()
654
655 args = self._make_command_line(interval, **kwargs)
656
657 self.log.info(_("Generating graph %s") % self)
658
659 rrd_graph = self.rrd_graph
660
661 # Add DEFs for all objects
662 if not any((e.startswith("DEF:") for e in rrd_graph)):
663 args += self._add_defs()
664
665 args += rrd_graph
666 args = self._add_vdefs(args)
667
668 # Convert arguments to string
669 args = [str(e) for e in args]
670
671 for arg in args:
672 self.log.debug(" %s" % arg)
673
674 with Environment(self.timezone, self.locale.lang):
675 graph = rrdtool.graphv("-", *args)
676
677 return {
678 "image" : graph.get("image"),
679 "image_height" : graph.get("image_height"),
680 "image_width" : graph.get("image_width"),
681 }
682
683 def graph_info(self):
684 """
685 Returns a dictionary with useful information
686 about this graph.
687 """
688 return {
689 "title" : self.graph_title,
690 "object_id" : self.object_id or "",
691 "template" : self.name,
692 }