debugger.IsomerDebugger.cli_compgraph()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 6
nop 2
dl 0
loc 9
rs 10
c 0
b 0
f 0
1
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
# Isomer - The distributed application framework
5
# ==============================================
6
# Copyright (C) 2011-2020 Heiko 'riot' Weinen <[email protected]> and others.
7
#
8
# This program is free software: you can redistribute it and/or modify
9
# it under the terms of the GNU Affero General Public License as published by
10
# the Free Software Foundation, either version 3 of the License, or
11
# (at your option) any later version.
12
#
13
# This program is distributed in the hope that it will be useful,
14
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
# GNU Affero General Public License for more details.
17
#
18
# You should have received a copy of the GNU Affero General Public License
19
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21
"""
22
23
24
Module: Debugger
25
================
26
27
Debugger overlord
28
29
30
"""
31
32
import json
33
import shutil
34
from collections import deque
35
from itertools import islice
36
from uuid import uuid4
37
38
import asciichartpy
39
from circuits.core import Timer
40
from circuits.core.events import Event
41
from circuits.core.handlers import reprhandler
42
from circuits.io import stdin
43
from isomer.logger import isolog, critical, error, warn, debug, verbose, verbosity
44
from isomer.component import ConfigurableComponent, handler
45
from isomer.events.client import send
46
from isomer.events.system import (
47
    frontendbuildrequest,
48
    componentupdaterequest,
49
    logtailrequest,
50
)
51
52
try:
53
    # noinspection PyPackageRequirements
54
    import objgraph
55
except ImportError:
56
    objgraph = None
57
58
try:
59
    # noinspection PyPackageRequirements
60
    from guppy import hpy
61
except ImportError:
62
    hpy = None
63
64
try:
65
    # noinspection PyPackageRequirements
66
    from pympler import tracker, muppy, summary
67
except ImportError:
68
    tracker = None
69
    muppy = None
70
    summary = None
71
72
73
class clicommand(Event):
74
    """Event to execute previously registered CLI event hooks"""
75
76
    def __init__(self, cmd, cmdargs, *args, **kwargs):
77
        super(clicommand, self).__init__(*args, **kwargs)
78
        self.cmd = cmd
79
        self.args = cmdargs
80
81
82
class cli_register_event(Event):
83
    """Event to register new command line interface event hooks"""
84
85
    def __init__(self, cmd, thing, *args, **kwargs):
86
        super(cli_register_event, self).__init__(*args, **kwargs)
87
        self.cmd = cmd
88
        self.thing = thing
89
90
91
class cli_help(Event):
92
    """Display this command reference
93
94
    Additional arguments:
95
        -v      Add detailed information about hook events in list
96
97
        command Show complete documentation of a hook command
98
    """
99
100
    pass
101
102
103
class cli_errors(Event):
104
    """Display errors in the live log"""
105
106
    pass
107
108
109
class cli_locations(Event):
110
    """Display all locations of running instance"""
111
112
    pass
113
114
115
class cli_log_level(Event):
116
    """Adjust log level
117
118
    Argument:
119
        [int]   New logging level (0-100)
120
    """
121
122
    pass
123
124
125
class cli_comp_graph(Event):
126
    """Draw current component graph"""
127
128
    pass
129
130
131
class cli_mem_summary(Event):
132
    """Output memory usage summary"""
133
134
    pass
135
136
137
class cli_mem_diff(Event):
138
    """Output difference in memory usage since last call"""
139
140
    pass
141
142
143
class cli_mem_hogs(Event):
144
    """Output most memory intense objects"""
145
146
    pass
147
148
149
class cli_mem_growth(Event):
150
    """Output data about memory growth"""
151
152
    pass
153
154
155
class cli_mem_heap(Event):
156
    """Output memory heap data"""
157
158
    pass
159
160
161
class cli_mem_chart(Event):
162
    """Output memory consumption chart
163
164
    Arguments:
165
        count   Integer specifying the number of last measurements to chart
166
167
    Additional options:
168
        -o      Omit first value
169
    """
170
171
    pass
172
173
174
class cli_exception_test(Event):
175
    """Raise test-exception to check exception handling"""
176
177
    pass
178
179
180
class TestException(BaseException):
181
    """Generic exception to test exception monitoring"""
182
183
    pass
184
185
186
class IsomerDebugger(ConfigurableComponent):
187
    """
188
    Handles various debug requests.
189
    """
190
191
    configprops = {
192
        "notificationusers": {
193
            "type": "array",
194
            "title": "Notification receivers",
195
            "description": "Users that should be notified about exceptions.",
196
            "default": [],
197
            "items": {"type": "string"},
198
        }
199
    }
200
    channel = "isomer-web"
201
202
    def __init__(self, root=None, *args):
203
        super(IsomerDebugger, self).__init__("DBG", *args)
204
205
        if not root:
206
            from isomer.logger import root
207
208
            self.root = root
209
        else:
210
            self.root = root
211
212
        if objgraph is None:
213
            self.log("Cannot use objgraph.", lvl=warn)
214
215
        try:
216
            self.fireEvent(cli_register_event("errors", cli_errors))
217
            self.fireEvent(cli_register_event("log_level", cli_log_level))
218
            self.fireEvent(cli_register_event("comp_graph", cli_comp_graph))
219
            self.fireEvent(cli_register_event("locations", cli_locations))
220
            self.fireEvent(cli_register_event("test_exception", cli_exception_test))
221
        except AttributeError:
222
            pass  # We're running in a test environment and root is not yet running
223
224
        try:
225
            self.tracker = tracker.SummaryTracker()
226
        except AttributeError:
227
            self.log("No pympler library for memory analysis installed.", lvl=warn)
228
229
        self.log("Started. Notification users: ", self.config.notificationusers)
230
231
    def _drawgraph(self):
232
        objgraph.show_backrefs(
233
            [self.root],
234
            max_depth=5,
235
            filter=lambda x: type(x) not in [list, tuple, set],
236
            highlight=lambda x: type(x) in [ConfigurableComponent],
237
            filename="backref-graph.png",
238
        )
239
        self.log("Backref graph written.", lvl=critical)
240
241
    @handler("cli_errors")
242
    def cli_errors(self, *args):
243
        """Display errors in the live log"""
244
245
        self.log("All errors since startup:")
246
        from isomer.logger import LiveLog
247
248
        for logline in LiveLog:
249
            if logline[1] >= error:
250
                self.log(logline, pretty=True)
251
252
    @handler("cli_log_level")
253
    def cli_log_level(self, *args):
254
        """Adjust log level"""
255
256
        new_level = int(args[0])
257
        self.log("Adjusting logging level to", new_level)
258
259
        verbosity["global"] = new_level
260
        verbosity["console"] = new_level
261
        verbosity["file"] = new_level
262
263
    @handler("cli_compgraph")
264
    def cli_compgraph(self, event):
265
        """Draw current component graph"""
266
267
        self.log("Drawing component graph")
268
        from circuits.tools import graph
269
270
        graph(self)
271
        self._drawgraph()
272
273
    @handler("cli_locations")
274
    def cli_locations(self, *args):
275
        """Display all locations of running instance"""
276
277
        self.log("All locations for this instance:")
278
        from isomer.misc.path import locations, get_path
279
280
        for path in locations:
281
            self.log(get_path(path, ""), pretty=True)
282
283
    @handler("cli_exception_test")
284
    def cli_exception_test(self, *args):
285
        """Raise test-exception to check exception handling"""
286
287
        raise TestException
288
289
    @handler("debug_store_json")
290
    def debug_store_json(self, event):
291
        """A debug-endpoint to store an event as json dump"""
292
293
        self.log("Storing received object to /tmp", lvl=critical)
294
        fp = open(
295
            "/tmp/isomer_debugger_" + str(event.user.useruuid) + "_" + str(uuid4()), "w"
296
        )
297
        json.dump(event.data, fp, indent=True)
298
        fp.close()
299
300
    @handler(logtailrequest)
301
    def logtailrequest(self, event):
302
        self.log("Log requested")
303
304
    @handler("exception", channel="*", priority=1.0)
305
    def _on_exception(self, error_type, value, traceback, handler=None, fevent=None):
306
        # TODO: Generate hashes and thus unique urls with exceptions and fill
307
        #  them out with this data:
308
        #  self.log('EXCEPTIONHANDLER:', error_type, value, traceback, lvl=critical)
309
        #  The idea is to have error pages in the documentation/public Isomer instance
310
        #  so people can discuss and get help on runtime errors, like with the
311
        #  exitcodes system in the documentation
312
313
        try:
314
            s = []
315
316
            if handler is None:
317
                handler = ""
318
            else:
319
                handler = reprhandler(handler)
320
321
            msg = "ERROR"
322
            msg += "{0:s} ({1:s}) ({2:s}): {3:s}\n".format(
323
                handler, repr(fevent), repr(error_type), repr(value)
324
            )
325
326
            s.append(msg)
327
            s.append("\n")
328
329
            isolog("\n".join(s), "\n".join(traceback),
330
                   lvl=critical, frame_ref=3, emitter="DEBUG")
331
332
            alert = {
333
                "component": "isomer.alert.manager",
334
                "action": "notify",
335
                "data": {
336
                    "type": "danger",
337
                    "message": "\n".join(s),
338
                    "title": "Exception Monitor",
339
                },
340
            }
341
            for user in self.config.notificationusers:
342
                self.fireEvent(send(None, alert, username=user, sendtype="user"))
343
344
        except Exception as e:
345
            self.log("Exception during exception handling: ", e, type(e), lvl=critical,
346
                     exc=True)
347
348
349
class CLI(ConfigurableComponent):
350
    """
351
    Command Line Interface support
352
353
    This is disabled by default.
354
    To enable the command line interface, use either the Configuration frontend,
355
    or the iso tool:
356
357
    .. code-block:: sh
358
359
        iso config enable CLI
360
361
    """
362
363
    configprops = {}
364
365
    def __init__(self, *args):
366
        super(CLI, self).__init__("CLI", *args)
367
368
        self.hooks = {}
369
370
        self.log("Started")
371
        stdin.register(self)
372
        self.fire(cli_register_event("help", cli_help))
373
374
    @handler("read", channel="stdin")
375
    def stdin_read(self, data):
376
        """read Event (on channel ``stdin``)
377
        This is the event handler for ``read`` events specifically from the
378
        ``stdin`` channel. This is triggered each time stdin has data that
379
        it has read.
380
        """
381
382
        data = data.strip().decode("utf-8")
383
        self.log("Incoming:", data, lvl=verbose)
384
385
        def show_error():
386
            self.log(
387
                "Unknown Command: '%s'. Use /help to get a list of enabled "
388
                "cli hooks" % data,
389
                lvl=warn,
390
            )
391
392
        if len(data) == 0:
393
            self.log("Use /help to get a list of enabled cli hooks")
394
            return
395
396
        if data[0] == "/":
397
            cmd = data[1:]
398
            args = []
399
            if " " in cmd:
400
                cmd, args = cmd.split(" ", maxsplit=1)
401
                args = args.split(" ")
402
            if cmd in self.hooks:
403
                self.log("Firing hooked event:", cmd, args, lvl=debug)
404
                self.fireEvent(self.hooks[cmd](*args))
405
            # TODO: Move these out, so we get a simple logic here
406
            elif cmd == "frontend":
407
                self.log(
408
                    "Sending %s frontend rebuild event"
409
                    % ("(forced)" if "force" in args else "")
410
                )
411
                self.fireEvent(
412
                    frontendbuildrequest(
413
                        force="force" in args, install="install" in args
414
                    ),
415
                    "setup",
416
                )
417
            elif cmd == "backend":
418
                self.log("Sending backend reload event")
419
                self.fireEvent(componentupdaterequest(force=False), "setup")
420
            else:
421
                show_error()
422
        else:
423
            show_error()
424
425
    @handler("cli_help")
426
    def cli_help(self, *args):
427
        """Print a list, and a short documentation of all CLI commands"""
428
429
        if len(args) == 0 or args[0].startswith("-"):
430
            self.log("Registered CLI hooks:")
431
            # TODO: Use std_table for a pretty table
432
            command_length = 5
433
            object_length = 5
434
            for hook in self.hooks:
435
                command_length = max(len(hook), command_length)
436
                object_length = max(len(str(self.hooks[hook])), object_length)
437
438
            if "-v" not in args:
439
                object_length = 0
440
441
            for hook in sorted(self.hooks):
442
                self.log(
443
                    "/%s %s: %s"
444
                    % (
445
                        hook.ljust(command_length),
446
                        (
447
                            " - " + str(self.hooks[hook]) if object_length != 0 else ""
448
                        ).ljust(object_length),
449
                        str(self.hooks[hook].__doc__).split("\n", 1)[0],
450
                    )
451
                )
452
        else:
453
            self.log("Help for command", args[0], ":")
454
            self.log(self.hooks[args[0]].__doc__)
455
456
    @handler("cli_register_event")
457
    def register_event(self, event):
458
        """Registers a new command line interface event hook as command"""
459
460
        self.log(
461
            "Registering event hook:", event.cmd, event.thing, pretty=True, lvl=verbose
462
        )
463
        self.hooks[event.cmd] = event.thing
464
465
466
class MemoryLogger(ConfigurableComponent):
467
    """
468
    Periodically logs memory consumption statistics
469
    """
470
471
    configprops = {
472
        "notificationusers": {
473
            "type": "array",
474
            "title": "Notification receivers",
475
            "description": "Users that should be notified about exceptions.",
476
            "default": [],
477
            "items": {
478
                "oneOf": [
479
                    {"type": "string"},
480
                    {"type": "null"}
481
                ]
482
            },
483
        },
484
        "interval": {
485
            "type": "integer",
486
            "title": "Interval",
487
            "description": "Memory growth snapshot interval",
488
            "default": 600,
489
        },
490
        "snapshot_diffs": {
491
            "type": "integer",
492
            "title": "History length (snapshots)",
493
            "description": "Number of memory snapshots diffs to keep",
494
            "default": 30
495
        },
496
        "size_diffs": {
497
            "type": "integer",
498
            "title": "History length (size)",
499
            "description": "Number of memory size diffs to keep",
500
            "default": 1000
501
        }
502
    }
503
    channel = "isomer-web"
504
505
    def __init__(self, *args):
506
        super(MemoryLogger, self).__init__("MEM", *args)
507
508
        if self.context.params['debug'] is True:
509
            self.log('Debug flag set, decreasing memory logger measurement '
510
                     'interval to 20 seconds')
511
            self.interval = 20
512
        else:
513
            self.interval = self.config.interval
514
515
        self.size = 0
516
        self.snapshot_diffs = deque(maxlen=self.config.snapshot_diffs)
517
        self.size_diffs = deque(maxlen=self.config.size_diffs)
518
519
        if hpy is not None:
520
            # noinspection PyCallingNonCallable
521
            self.heapy = hpy()
522
        else:
523
            self.log("Cannot use heapy. guppy package missing?", lvl=warn)
524
525
        if objgraph is None:
526
            self.log("Cannot use objgraph.", lvl=warn)
527
528
        try:
529
            self.fireEvent(cli_register_event("mem_growth", cli_mem_growth))
530
            self.fireEvent(cli_register_event("mem_hogs", cli_mem_hogs))
531
            self.fireEvent(cli_register_event("mem_heap", cli_mem_heap))
532
            self.fireEvent(cli_register_event("mem_summary", cli_mem_summary))
533
            self.fireEvent(cli_register_event("mem_diff", cli_mem_diff))
534
            self.fireEvent(cli_register_event("mem_chart", cli_mem_chart))
535
536
        except AttributeError:
537
            pass  # We're running in a test environment and root is not yet running
538
539
        try:
540
            self.tracker = tracker.SummaryTracker()
541
            self.tracking_timer = Timer(
542
                self.interval, Event.create("memlog-timer"), persist=True
543
            ).register(self)
544
545
        except AttributeError:
546
            self.tracking_timer = None
547
            self.log("No pympler library for memory analysis installed.", lvl=warn)
548
549
        self.log("Started. Notification users: ", self.config.notificationusers)
550
551
    @handler("memlog-timer")
552
    def memlog_timer(self):
553
554
        growth = 0
555
        diff = self.tracker.diff()
556
557
        for element in diff:
558
            growth += element[2]
559
560
        self.size += growth
561
        self.snapshot_diffs.append(diff)
562
        self.size_diffs.append(growth)
563
564
        log_level = debug
565
        if growth > (self.size * 0.1) and len(self.size_diffs) > 1:
566
            self.log('Memory growth exceeded 10%!', lvl=warn)
567
            log_level = warn
568
        self.log("Memory: total %i growth %i (in %i seconds)" % (
569
            self.size, growth, self.interval), lvl=log_level)
570
571
    @handler("cli_mem_summary")
572
    def cli_mem_summary(self, event):
573
        """Output memory usage summary"""
574
575
        all_objects = muppy.get_objects()
576
        state = summary.summarize(all_objects)
577
        summary.print_(state)
578
579
    @handler("cli_mem_diff")
580
    def cli_mem_diff(self, event):
581
        """Output difference in memory usage since last call"""
582
583
        self.tracker.print_diff()
584
585
    @handler("cli_mem_hogs")
586
    def cli_mem_hogs(self, *args):
587
        """Output most memory intense objects"""
588
589
        self.log("Memory hogs:", lvl=critical)
590
        objgraph.show_most_common_types(limit=20)
591
592
    @handler("cli_mem_growth")
593
    def cli_mem_growth(self, *args):
594
        """Output data about memory growth"""
595
596
        self.log("Memory growth since last call:", lvl=critical)
597
        objgraph.show_growth()
598
599
    @handler("cli_mem_heap")
600
    def cli_mem_heap(self, *args):
601
        """Output memory heap data"""
602
603
        self.log("Heap log:", self.heapy.heap(), lvl=critical)
604
605
    @handler("cli_mem_chart")
606
    def cli_mem_chart(self, *args):
607
        """Output memory consumption chart"""
608
609
        # Subtract 11 columns for the chart axis
610
        width = shutil.get_terminal_size().columns - 11
611
612
        config = {
613
            'colors': [
614
                asciichartpy.blue,
615
                asciichartpy.red
616
            ],
617
            'height': 10,
618
            'format': '{:6.0f}kB'
619
        }
620
621
        total_length = len(self.size_diffs)
622
623
        try:
624
            count = int(args[-1])
625
        except (TypeError, ValueError, IndexError):
626
            count = total_length
627
628
        if count > total_length:
629
            self.log('Only %i measurements available!' % total_length, lvl=warn)
630
            count = total_length
631
632
        if count > width:
633
            self.log('Clipping %i values due to terminal width of only '
634
                     '%i columns!' % (count - width, width), lvl=warn)
635
            count = width
636
637
        size_k = [x / 1024.0 for x in islice(
638
            self.size_diffs, len(self.size_diffs) - count, len(self.size_diffs)
639
        )]
640
641
        if len(args) > 0:
642
            if "-o" in args:
643
                size_k.pop(0)
644
645
        length = len(size_k)
646
647
        self.log(
648
            "Memory consumption (%i measurements, "
649
            "%i seconds interval, %3.0f minutes total)" % (
650
                length,
651
                self.interval,
652
                (length * self.interval / 60.0)
653
            )
654
        )
655
        self.log("\n%s" % asciichartpy.plot(size_k, config), nc=True)
656