diff options
Diffstat (limited to 'bin/xcapture-bpf')
-rwxr-xr-x | bin/xcapture-bpf | 732 |
1 files changed, 732 insertions, 0 deletions
diff --git a/bin/xcapture-bpf b/bin/xcapture-bpf new file mode 100755 index 0000000..e492094 --- /dev/null +++ b/bin/xcapture-bpf @@ -0,0 +1,732 @@ +#!/usr/bin/env python3 + +# xcapture-bpf -- Always-on profiling of Linux thread activity, by Tanel Poder [https://tanelpoder.com] +# Copyright 2024 Tanel Poder +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# SPDX-License-Identifier: GPL-2.0-or-later + +__version__ = "2.0.3" +__author__ = "Tanel Poder" +__date__ = "2024-06-27" +__description__ = "Always-on profiling of Linux thread activity using eBPF." +__url__ = "https://0x.tools" + +DEFAULT_GROUP_BY = "st,username,comm,syscall" # for xtop mode +DECODE_CHARSET = "utf-8" +XTOP_MAX_LINES = 25 # how many top output lines to print +BLOCK_CHARS = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'] # for fancy viz + +import os, sys, io, pwd, time, ctypes, platform, re, shutil, argparse, signal +from collections import defaultdict +from datetime import datetime +from bcc import BPF, PerfType, PerfSWConfig + +# distro package might not be present +try: + import distro +except ImportError: + distro = None + pass + +# all available fields with descriptions (if you add more fields to thread_state_t in BPF/C, add them here) +available_fdescr = [ ('timestamp' , 'sample timestamp') + , ('st' , 'short thread state') + , ('tid' , 'thread/task id') + , ('pid' , 'process/thread group id') + , ('username' , 'username or user id if not found') + , ('comm' , 'task comm digits deduplicated') + , ('comm2' , 'task comm actual') + , ('syscall' , 'system call') + , ('cmdline' , 'argv0 command line digits deduplicated') + , ('cmdline2' , 'argv0 command line actual') + , ('offcpu_u' , 'user stack id when thread went off CPU') + , ('offcpu_k' , 'kernel stack id when thread went off CPU') + , ('oncpu_u' , 'recent user stack id if the thread was on CPU') + , ('oncpu_k' , 'recent kernel stack id if the thread was on CPU') + , ('waker_tid' , 'thread ID that woke up this thread last') + , ('sch' , 'thread state flags for scheduler nerds') + ] + +available_fields = [] +for f in available_fdescr: + available_fields.append(f[0]) + +# default output fields for ungrouped full detail output +output_fields = [ 'timestamp', 'st', 'tid', 'pid', 'username', 'comm', 'syscall', 'cmdline' + , 'offcpu_u', 'offcpu_k', 'oncpu_u', 'oncpu_k', 'waker_tid', 'sch' ] + + +# syscall id to name translation (todo: fix aarch64 include file lookup) +def extract_system_call_ids(unistd_64_fh): + syscall_id_to_name = {} + + # strip 3264bit prefixes from syscall names + for name_prefix in ['__NR_', '__NR3264_']: + for line in unistd_64_fh.readlines(): + tokens = line.split() + if tokens and len(tokens) == 3 and tokens[0] == '#define' and tokens[2].isnumeric() is True: + _, s_name, s_id = tokens + s_id = int(s_id) + if s_name.startswith(name_prefix): + s_name = s_name[len(name_prefix):] + syscall_id_to_name[s_id] = s_name + + return syscall_id_to_name + +def get_system_call_names(): + psn_dir=os.path.dirname(os.path.realpath(__file__)) + kernel_ver=platform.release().split('-')[0] + + # this probably needs to be improved for better platform support + if platform.machine() == 'aarch64': + unistd_64_paths = ['/usr/include/asm-generic/unistd.h'] + else: + unistd_64_paths = [ '/usr/include/asm/unistd_64.h', '/usr/include/x86_64-linux-gnu/asm/unistd_64.h' + , '/usr/include/asm-x86_64/unistd.h', '/usr/include/asm/unistd.h' + , psn_dir+'/syscall_64_'+kernel_ver+'.h', psn_dir+'/syscall_64.h'] + + for path in unistd_64_paths: + try: + with open(path) as f: + return extract_system_call_ids(f) + except IOError as e: + pass + + raise Exception('unistd_64.h not found in' + ' or '.join(unistd_64_paths) + '.\n' + + ' You may need to "dnf install kernel-headers" or "apt-get install libc6-dev"\n') + +# syscall lookup table +syscall_id_to_name = get_system_call_names() + + +# task states +TASK_RUNNING = 0x00000000 +TASK_INTERRUPTIBLE = 0x00000001 +TASK_UNINTERRUPTIBLE = 0x00000002 +TASK_STOPPED = 0x00000004 +TASK_TRACED = 0x00000008 + +EXIT_DEAD = 0x00000010 +EXIT_ZOMBIE = 0x00000020 +EXIT_TRACE = (EXIT_ZOMBIE | EXIT_DEAD) + +TASK_PARKED = 0x00000040 +TASK_DEAD = 0x00000080 +TASK_WAKEKILL = 0x00000100 +TASK_WAKING = 0x00000200 +TASK_NOLOAD = 0x00000400 +TASK_NEW = 0x00000800 +TASK_RTLOCK_WAIT = 0x00001000 +TASK_FREEZABLE = 0x00002000 +TASK_FREEZABLE_UNSAFE = 0x00004000 # depends on: IS_ENABLED(CONFIG_LOCKDEP) +TASK_FROZEN = 0x00008000 +TASK_STATE_MAX = 0x00010000 # as of linux kernel 6.9 + +##define TASK_STATE_TO_CHAR_STR "RSDTtXZxKWPN" + +task_states = { + 0x00000000: "R", # "RUNNING", + 0x00000001: "S", # "INTERRUPTIBLE", + 0x00000002: "D", # UNINTERRUPTIBLE", + 0x00000004: "T", # "STOPPED", + 0x00000008: "t", # "TRACED", + 0x00000010: "X", # "EXIT_DEAD", + 0x00000020: "Z", # "EXIT_ZOMBIE", + 0x00000040: "P", # "PARKED", + 0x00000080: "dd",# "DEAD", + 0x00000100: "wk",# "WAKEKILL", + 0x00000200: "wg",# "WAKING", + 0x00000400: "I", # "NOLOAD", + 0x00000800: "N", # "NEW", + 0x00001000: "rt",# "RTLOCK_WAIT", + 0x00002000: "fe",# "FREEZABLE", + 0x00004000: "fu",# "__TASK_FREEZABLE_UNSAFE = (0x00004000 * IS_ENABLED(CONFIG_LOCKDEP))" + 0x00008000: "fo",# "FROZEN" +} + + +def get_task_state_name(task_state): + if task_state == 0: + return "R" + if task_state & TASK_NOLOAD: # idle kthread waiting for work + return "I" + + names = [] + for state, name in task_states.items(): + if task_state & state: + names.append(name) + + return "+".join(names) + + +# is task state interesting ("active") according to your rules +# mode=active: any states that should be captured and printed out (including perf/on-cpu samples) +# mode=offcpu: states that are relevant for offcpu stack printing (the BPF program doesn't clear up previous offcpu stackids) +# mode=oncpu: states that are relevant for on-cpu stack printing (don't print previous oncpu stacks if a task sample is not on CPU) +def is_interesting(st, syscall, comm, mode="active"): + if mode == "active": + if st[0] in ['R','D', 'T', 't']: + return True + if st[0] == 'S': + if current_syscall == 'io_getevents' and comm.startswith('ora'): + return True + + if mode == "offcpu": + if st[0] in ['D', 'T', 't'] or st.startswith('RQ'): # there may be occasinal states like "D+wk" reported + return True + if st[0] == 'S': + if current_syscall == 'io_getevents' and comm.startswith('ora'): + return True + + if mode == "oncpu": + if st[0] == 'R': + return True + + return False + +# translate uid to username (no container/uid namespace support right now) +def get_username(uid): + try: + username = pwd.getpwuid(uid).pw_name + return username + except KeyError: + return str(uid) + + + +def print_fields(rows, columns, linelimit=0): + columns = [col.rstrip() for col in columns] # strip as colname might have extra spaces passed in for width/formatting + col_widths = {} + # column width auto-sizing + for col in columns: + col_length = len(col) # the col may have extra trailing spaces as a formatting directive + max_value_length = max((len(str(row[col])) for row in rows if col in row), default=0) + col_widths[col] = max(col_length, max_value_length) + + header1 = "=== Active Threads " + header2 = " | ".join(f"{col:<{col_widths[col]}}" for col in columns) + + print(header1 + "=" * (len(header2) - len(header1)) + "\n") + print(header2) + print("-" * len(header2)) + + for i, row in enumerate(rows): + line = " | ".join( + f"{row[col]:>{col_widths[col]}.2f}" if col in ["seconds", "samples", "avg_thr"] + else f"{str(row[col]):<{col_widths[col]}}" + if col in row else ' ' * col_widths[col] for col in columns + ) + print(line) + + # dont break out if linelimit is at its default 0 + if linelimit and i >= linelimit - 1: + break + +def print_header_csv(columns): + header = ",".join(f"{col.upper()}" for col in columns) + print(header) + +def print_fields_csv(rows, columns): + for i, row in enumerate(rows): + line = ",".join(f"{row[col]}" for col in columns) + print(line) + +def get_ustack_traces(ustack_traces, ignore_ustacks={}, strip_args=True): + exclusions = ['__GI___clone3'] + dedup_map = {} + lines = [] + + for stack_id, pid in output_ustack: + if stack_id and stack_id >= 0 and stack_id not in ignore_ustacks: # todo: find why we have Null/none stackids in this map + line = f"ustack {stack_id:6} " + stack = list(ustack_traces.walk(stack_id)) + for addr in reversed(stack): # reversed(stack): + func_name = b.sym(addr, pid).decode(DECODE_CHARSET, 'replace') + if func_name not in exclusions: + if strip_args: + func_name = re.split('[<(]', func_name)[0] + line += "->" + (func_name if func_name != '[unknown]' else '{:x}'.format(addr)) + + dedup_map[stack_id] = line + + for stack_id in sorted(dedup_map): + lines.append(dedup_map[stack_id]) + + return lines + +def get_kstack_traces(kstack_traces, ignore_kstacks={}): + exclusions = ['entry_SYSCALL_64_after_hwframe', 'do_syscall_64', 'x64_sys_call' + , 'ret_from_fork_asm', 'ret_from_fork', '__bpf_trace_sched_switch', '__traceiter_sched_switch' + , 'el0t_64_sync', 'el0t_64_sync_handler', 'el0_svc', 'do_el0_svc', 'el0_svc_common', 'invoke_syscall' ] + lines = [] + + for k, v in kstack_traces.items(): + stack_id = k.value + if stack_id in output_kstack and stack_id not in ignore_kstacks: + line = f"kstack {stack_id:6} " + if stack_id >= 0: + stack = list(kstack_traces.walk(stack_id)) + + for addr in reversed(stack): + func = b.ksym(addr).decode(DECODE_CHARSET, 'replace') + if func not in exclusions and not func.startswith('bpf_'): + line += "->" + b.ksym(addr).decode(DECODE_CHARSET, 'replace') + + lines.append(line) + + return lines + + +def pivot_stack_traces(traces): + pivoted_traces = [] + for trace in traces: + parts = trace.split("->") + pivoted_traces.append(parts) + + max_length = max(len(trace) for trace in pivoted_traces) + for trace in pivoted_traces: + while len(trace) < max_length: + trace.append("") + + return pivoted_traces + +def calculate_columns(pivoted_traces, max_line_length): + max_length = max(len(part) for trace in pivoted_traces for part in trace) + return max(1, max_line_length // (max_length + 3)) + +def print_pivoted_dynamic(traces, max_line_length): + num_traces = len(traces) + start = 0 + + while start < num_traces: + end = start + 1 + while end <= num_traces: + subset_traces = traces[start:end] + pivoted_traces = pivot_stack_traces(subset_traces) + num_columns = calculate_columns(pivoted_traces, max_line_length) + + if num_columns < end - start: + break + + end += 1 + + end -= 1 + subset_traces = traces[start:end] + pivoted_traces = pivot_stack_traces(subset_traces) + + max_length = max(len(part) for trace in pivoted_traces for part in trace) + + print("-" * max_line_length) + for row in zip(*pivoted_traces): + print(" | ".join(f"{part:<{max_length}}" for part in row) + ' |') + + start = end + +# stack printing and formatting choice driver function +def print_stacks_if_nerdmode(): + if args.giant_nerd_mode and stackmap: + # printing stacktiles first, so the task state info is in the bottom of terminal output + (term_width, term_height) = shutil.get_terminal_size() + + print_pivoted_dynamic(get_kstack_traces(stackmap), max_line_length=term_width) + print() + + print_pivoted_dynamic(get_ustack_traces(stackmap), max_line_length=term_width) + print() + + if args.nerd_mode: + for s in get_kstack_traces(stackmap): + print(s) + print() + for s in get_ustack_traces(stackmap): + print(s) + +# group by for reporting +def group_by(records, column_names, sample_attempts_in_set, time_range_in_set): + total_records = len(records) + grouped_data = defaultdict(lambda: {'samples': 0}) + + for record in records: + key = tuple(record[col] for col in column_names) + if key not in grouped_data: + grouped_data[key].update({col: record[col] for col in column_names}) + grouped_data[key]['samples'] += 1 + + grouped_list = list(grouped_data.values()) + + for item in grouped_list: + item['avg_thr'] = round(item['samples'] / sample_attempts_in_set, 2) + item['seconds'] = round(item['samples'] * (time_range_in_set / sample_attempts_in_set), 2) + + # fancy viz + pct = item['samples'] / total_records + full_blocks = int(pct * 10) + remainder = (pct * 80) % 8 + visual = '█' * full_blocks + if remainder > 0: + visual += BLOCK_CHARS[int(remainder)] + item['visual_pct'] = visual + #ascii also possible + #item['visual_pct'] = '#' * int(pct * 10) + + + return grouped_list + + +# main() +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + +# args +parser = argparse.ArgumentParser(description=__description__) +parser.add_argument('-x', '--xtop', action='store_true', help='Run in aggregated top-thread-activity (xtop) mode') +parser.add_argument('-d', dest="report_seconds", metavar='report_seconds', type=int, default=5, help='xtop report printing interval (default: %(default)ds)') +parser.add_argument('-f', '--sample-hz', default=20, type=int, help='xtop sampling frequency in Hz (default: %(default)d)') +parser.add_argument('-g', '--group-by', metavar='csv-columns', default=DEFAULT_GROUP_BY, help='Full column list what to group by') +parser.add_argument('-G', '--append-group-by', metavar='append-csv-columns', default=None, help='List of additional columns to default cols what to group by') +parser.add_argument('-n', '--nerd-mode', action='store_true', help='Print out relevant stack traces as wide output lines') +parser.add_argument('-N', '--giant-nerd-mode', action='store_true', help='Print out relevant stack traces as stacktiles') +parser.add_argument('-c', '--clear-screen', action='store_true', help='Clear screen before printing next output') +parser.add_argument('-V', '--version', action='version', version=f"%(prog)s {__version__} by {__author__} [{__url__}]", help='Show the program version and exit') +parser.add_argument('-o', '--output-dir', type=str, default=None, help=f'Directory path where to write the output CSV files') +parser.add_argument('-l', '--list', default=None, action='store_true', help='list all available columns for display and grouping') + +args = parser.parse_args() + +if args.list: + for f in available_fdescr: + print(f'{f[0]:15} {f[1]}') + sys.exit(0) + +if args.clear_screen and args.output_dir: + print("Error: --clear-screen (interactive) and --output-dir (continuous logging) are mutually exclusive, use only one option.") + sys.exit(1) + +# handle xtop -g and -G group by columns (and same -g/-G options work for non-xtop output col addition too) +# args.group_by defaults to DEFAULT_GROUP_BY +groupby_fields = args.group_by.split(',') + +if args.xtop: + groupby_fields = groupby_fields + args.append_group_by.split(',') if args.append_group_by else groupby_fields + used_fields = groupby_fields # todo +else: + output_fields = output_fields + args.append_group_by.split(',') if args.append_group_by else output_fields + used_fields = output_fields + +if set(used_fields) - set(available_fields): + print("Error: incorrect group by field name specified, use --list option see allowed columns") + exit(1) + +# eBPF programs have be loaded as root +if os.geteuid() != 0: + print("Error: you need to run this command as root") + sys.exit(1) + +# ready to go +progname = "xtop" if args.xtop else "xcapture-bpf" +kernname = platform.release().split('-')[0] +archname = platform.machine() +distroid = distro.id().title() if distro else '' +distrover = distro.version() if distro else '' +sf = None # fd for separate stackfile in continuous csv sampling mode + +print(f'=== [0x.tools] {progname} {__version__} BETA by {__author__}. {distroid} Linux {distrover} {kernname} {archname}') + +# open and load the BPF instrumenter +with open(os.path.dirname(os.path.abspath(__file__)) + '/xcapture-bpf.c', 'r') as file: + bpf_text = file.read() + +# set up global variables for conditionally inserting stack capture code +offcpu_u = 'offcpu_u' in used_fields +offcpu_k = 'offcpu_k' in used_fields +offcpu_stacks = offcpu_u or offcpu_k +oncpu_stacks = ('oncpu_u' in used_fields or 'oncpu_k' in used_fields) +cmdline = ('cmdline' in used_fields or 'cmdline2' in used_fields) + +# dynamic compilation of features that are needed +ifdef = '' +if offcpu_u: + ifdef += '#define OFFCPU_U 1\n' +if offcpu_k: + ifdef += '#define OFFCPU_K 1\n' +if offcpu_stacks: + ifdef += '#define OFFCPU_STACKS 1\n' +if oncpu_stacks: + ifdef += '#define ONCPU_STACKS 1\n' +if cmdline: + ifdef += '#define CMDLINE 1\n' + + +print('=== Loading BPF...') +b = BPF(text= ifdef + bpf_text) + +# Software CPU_CLOCK is useful in cloud & VM environments where perf hardware events +# are not available, but software clocks don't measure what happens when CPUs are in +# critical sections when most interrupts are disabled +b.attach_perf_event(ev_type=PerfType.SOFTWARE, ev_config=PerfSWConfig.CPU_CLOCK + , fn_name="update_cpu_stack_profile" + , sample_freq=2) # args.sample_hz if args.xtop else 1 + +# start sampling the Task State Array +tsa = b.get_table("tsa") + +if oncpu_stacks or offcpu_stacks: + stackmap = b.get_table("stackmap") +else: + stackmap = {} + +# get own pid so to not display it in output +mypid = os.getpid() +print(f"=== Ready (mypid {mypid})\n") + +# regex for replacing digits in "comm" for better grouping and reporting (comm2 shows original) +trim_comm = re.compile(r'\d+') + +written_kstacks = {} # stack ids already written to csv (in -o mode) +written_ustacks = {} + +first_report_printed = False # show first xtop report quicker +csv_header_printed = False + +while True: + try: + output_kstack = {} # map of stack_ids seen so far + output_ustack = {} + output_records = [] + + sample_start = time.time() + duration = (args.report_seconds if args.xtop and first_report_printed else 1) + sample_end = sample_start + duration # todo: 1 Hz for raw/csv output for now + first_report_printed = True + samples_attempted = 0 # not all TSA samples contain active threads of interest, this tells us how many samples we really took + + while time.time() < sample_end: + samples_attempted += 1 + ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f') + i = tsa.items()[0] + + for i in tsa.items(): + save_record = True + # extract python values from BPF ctypes, return '-' if there's no match + fields_dict = {field[0]: getattr(i[1], field[0], '-') for field in i[1]._fields_} + + if fields_dict['tid'] == mypid: + continue + + # additional fields for adding human readable info (not using None as it would be printed out as "None") + fields_dict['st'] = '' + fields_dict['sch'] = '' # for scheduler nerds + fields_dict['state_flags'] = '' # full scheduler state bitmap + fields_dict['username'] = '' + fields_dict['syscall'] = '' + fields_dict['comm2'] = '' + fields_dict['cmdline2'] = '' + + current_syscall = syscall_id_to_name.get(fields_dict['syscall_id'], '-') if fields_dict['syscall_set'] else '-' + comm = str(fields_dict['comm'], DECODE_CHARSET) + + in_sched_migrate = fields_dict['in_sched_migrate'] + in_sched_wakeup = fields_dict['in_sched_wakeup'] + in_sched_waking = fields_dict['in_sched_waking'] + is_running_on_cpu = fields_dict['is_running_on_cpu'] + + # we use state for conditionally printing out things like offcpu_stack etc + state_suffix = '' + state = get_task_state_name(fields_dict['state']) + + if state == 'R' and not is_running_on_cpu: # runnable on runqueue + state += 'Q' + + enriched_fields = {"timestamp": ts[:-3]} + + for field_name in fields_dict: + if not field_name in used_fields: + continue + + outv = None # enriched value + if field_name in ['state', 'st']: + if is_interesting(state, current_syscall, comm): + outv = state + else: + save_record = False + break + + elif field_name.startswith('comm'): + val = fields_dict['comm'] # source field is "comm" regardless of potential comm2 output field name + if isinstance(val, bytes): + outv = str(val, DECODE_CHARSET) + else: + outv = str(val) + if field_name == 'comm': # only trim "comm", but not comm2 that is the unaltered string + outv = re.sub(trim_comm, '*', outv) + + elif field_name.startswith('cmdline'): + val = fields_dict['cmdline'] + if isinstance(val, bytes): + outv = str(val, DECODE_CHARSET) + else: + outv = str(val) + if field_name == 'cmdline': + outv = re.sub(trim_comm, '*', outv) + + elif field_name == 'syscall': + outv = current_syscall + + elif field_name == 'username': + outv = get_username(fields_dict['uid']) + + elif field_name == ('offcpu_k'): # kstack id + val = fields_dict[field_name] + # runnable state can be R or RQ: RQ is also off CPU, so will capture it + if is_interesting(state, current_syscall, comm, 'offcpu') and val > 0: + outv = val + output_kstack[val] = True + else: + outv = '-' + + elif field_name == ("offcpu_u"): # ustack id + val = fields_dict[field_name] + if is_interesting(state, current_syscall, comm, 'offcpu') and val > 0: + outv = val + # using pid/tgid here, address space is same for all threads in a process + output_ustack[val, fields_dict['pid']] = True + else: + outv = '-' + + elif field_name == ('oncpu_k'): + val = fields_dict[field_name] + # only print the perf-cpu samples when actually caught on cpu (not runqueue) for now + if is_interesting(state, current_syscall, comm, 'oncpu') and val > 0: + outv = val + output_kstack[val] = True + else: + outv = '-' + + elif field_name == ("oncpu_u"): + val = fields_dict[field_name] + if is_interesting(state, current_syscall, comm, 'oncpu') and val > 0: + outv = val + # using pid/tgid here, address space is same for all threads in a process + output_ustack[val, fields_dict['pid']] = True + else: + outv = '-' + + elif field_name == 'sch': + # (in_sched_waking, in_sched_wakeup, is_running_on_cpu) + outv = '-' if in_sched_migrate else '_' + outv += '-' if in_sched_waking else '_' + outv += '-' if in_sched_wakeup else '_' + outv += '-' if is_running_on_cpu else '_' + + else: + val = fields_dict[field_name] + if isinstance(val, bytes): + outv = str(val, DECODE_CHARSET) + else: + outv = str(val) + + enriched_fields[field_name] = outv + + if save_record: + output_records.append(enriched_fields) + + time.sleep(1 / (args.sample_hz if args.xtop else 1)) + + if output_records: + # csv output mode will not do any terminal stuff + if args.output_dir: + outfile = args.output_dir + '/threads_' + ts[:13].replace(' ', '.') + '.csv' + + if os.path.isfile(outfile): # special case if xcapture-bpf has been restarted within the same hour + csv_header_printed = True + + if sys.stdout.name != outfile: # create a new output file when the hour changes + csv_header_printed = False # new file + sys.stdout = open(outfile, 'a') + + if not csv_header_printed: + print_header_csv(output_fields) + csv_header_printed = True + + print_fields_csv(output_records, output_fields) + + # stackfile is created once and name doesn't change throughout xcapture process lifetime + if not sf: + stackfile = args.output_dir + '/stacks_' + ts[:13].replace(' ', '.') + '.csv' + sf = open(stackfile, 'a') + + if sf: + for s in get_kstack_traces(stackmap, ignore_kstacks=written_kstacks): + print(s, file=sf) + written_kstacks[int(s.split()[1])] = True + #print(written_kstacks, file=sf) + + for s in get_ustack_traces(stackmap, ignore_ustacks=written_ustacks): + print(s, file=sf) + written_ustacks[int(s.split()[1])] = True + #print(written_ustacks, file=sf) + + sf.flush() + + else: + if args.clear_screen: # interactive (xtop) + buffer = io.StringIO() + sys.stdout = buffer + + print_stacks_if_nerdmode() + print() + print() + + if args.xtop: + total_records = len(output_records) + # a new field "samples" shows up (count(*)) + grouped_list = group_by(output_records, groupby_fields, samples_attempted, sample_end - sample_start) + ordered_aggr = sorted(grouped_list, key=lambda x: x['samples'], reverse=True) + print_fields(ordered_aggr, ['seconds', 'avg_thr', 'visual_pct'] + groupby_fields, linelimit=XTOP_MAX_LINES) + + print() + print() + print(f'sampled: {samples_attempted} times, avg_thr: {round(total_records / samples_attempted, 2)}') + print(f'start: {ts[:19]}, duration: {duration}s') + + if args.clear_screen: + # terminal size may change over time + (term_width, term_height) = shutil.get_terminal_size() + + for x in range(1, term_height - min(len(ordered_aggr), XTOP_MAX_LINES) - 9): # header/footer lines + print() + else: + print() + + else: # wide raw terminal output + print_fields(output_records, output_fields) + print() + print() + + if args.clear_screen: + os.system('clear') + output = buffer.getvalue() + sys.stdout = sys.__stdout__ + print(output) + + sys.stdout.flush() + + except KeyboardInterrupt: + exit(0) + #signal.signal(signal.SIGINT, signal.SIG_IGN) + + +# That's all, folks! |