flamegraph-textual 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from flamegraph_textual.models import Frame, Profile, SampleType
4
+ from flamegraph_textual.parsers import parse
5
+ from flamegraph_textual.render.app import FlameGraphScroll, FlameshowApp
6
+ from flamegraph_textual.render.flamegraph import FlameGraph, FrameMap, add_array
7
+ from flamegraph_textual.view import FlameGraphView
8
+
9
+
10
+ class FlameGraphApp(FlameshowApp):
11
+ """Convenience alias for embedding a full flamegraph app."""
12
+
13
+
14
+ __all__ = [
15
+ "FlameGraph",
16
+ "FlameGraphApp",
17
+ "FlameGraphScroll",
18
+ "FlameGraphView",
19
+ "Frame",
20
+ "FrameMap",
21
+ "Profile",
22
+ "SampleType",
23
+ "add_array",
24
+ "parse",
25
+ ]
@@ -0,0 +1,67 @@
1
+ import random
2
+ import logging
3
+
4
+ from textual.color import Color
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class ColorPlatteBase:
10
+ def __init__(self):
11
+ self.assigned_color = {}
12
+
13
+ def get_color(self, key):
14
+ if key not in self.assigned_color:
15
+ self.assigned_color[key] = self.assign_color(key)
16
+ return self.assigned_color[key]
17
+
18
+ def assign_color(self, key):
19
+ raise NotImplementedError
20
+
21
+
22
+ class LinaerColorPlatte(ColorPlatteBase):
23
+ def __init__(
24
+ self,
25
+ start_color=Color.parse("#CD0000"),
26
+ end_color=Color.parse("#FFE637"),
27
+ ) -> None:
28
+ super().__init__()
29
+ self.assigned_color = {}
30
+ self.start_color = start_color
31
+ self.end_color = end_color
32
+ self.index = 0
33
+ self.platte = self.generate_platte()
34
+
35
+ def assign_color(self, key):
36
+ color = self.platte[self.index]
37
+ self.index += 1
38
+ if self.index == len(self.platte):
39
+ self.index = 0
40
+
41
+ logger.debug("assign color=%s", color)
42
+ return color
43
+
44
+ def generate_platte(self):
45
+ color_platte = []
46
+ for factor in range(0, 100, 5):
47
+ color_platte.append(
48
+ self.start_color.blend(self.end_color, factor / 100)
49
+ )
50
+ return color_platte
51
+
52
+
53
+ class FlameGraphRandomColorPlatte(ColorPlatteBase):
54
+ def __init__(self) -> None:
55
+ super().__init__()
56
+ self.assigned_color = {}
57
+
58
+ def assign_color(self, *args):
59
+ return Color(
60
+ 205 + int(50 * random.random()),
61
+ 0 + int(230 * random.random()),
62
+ 0 + int(55 * random.random()),
63
+ )
64
+
65
+
66
+ flamegraph_random_color_platte = FlameGraphRandomColorPlatte()
67
+ linaer_color_platte = LinaerColorPlatte()
@@ -0,0 +1,4 @@
1
+ VIEW_INFO_COLOR = "#ffffff"
2
+ VIEW_INFO_OTHER_COLOR = "#8884FF"
3
+ SELECTED_PARENTS_BG_COLOR_BLEND_TO = "#8b0000"
4
+ SELECTED_PARENTS_BG_COLOR_BLEND_FACTOR = 0.5
@@ -0,0 +1,14 @@
1
+ class FlamegraphTextualException(Exception):
2
+ """Base exception for flamegraph_textual."""
3
+
4
+
5
+ class ProfileParseException(FlamegraphTextualException):
6
+ """Can not parse the profile."""
7
+
8
+
9
+ class UsageError(FlamegraphTextualException):
10
+ """Usage Error."""
11
+
12
+
13
+ class RenderException(FlamegraphTextualException):
14
+ """Error during flamegraph render."""
@@ -0,0 +1,204 @@
1
+ from dataclasses import dataclass, field
2
+ import datetime
3
+ import logging
4
+ import time
5
+ from typing import Dict, List, Set
6
+ from typing_extensions import Self
7
+
8
+ from rich.style import Style
9
+ from rich.text import Text
10
+
11
+ from flamegraph_textual.utils import sizeof
12
+
13
+ from flamegraph_textual.runtime import r
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Frame:
20
+ def __init__(
21
+ self, name, _id, children=None, parent=None, values=None, root=None
22
+ ) -> None:
23
+ self.name = name
24
+ self._id = _id
25
+ if children:
26
+ self.children = children
27
+ else:
28
+ self.children = []
29
+ self.parent = parent
30
+ if not values:
31
+ self.values = []
32
+ else:
33
+ self.values = values
34
+
35
+ self.root = root
36
+
37
+ def pile_up(self, childstack: Self):
38
+ childstack.parent = self
39
+
40
+ for exist_child in self.children:
41
+ # added to exist, no need to create one
42
+ if exist_child.name == childstack.name:
43
+ # some cases, childstack.children total value not equal to
44
+ # childstack.values
45
+ # so, we need to add values of "parent" instead of add values
46
+ # by every child
47
+ exist_child.values = list(
48
+ map(sum, zip(exist_child.values, childstack.values))
49
+ )
50
+
51
+ for new_child in childstack.children:
52
+ exist_child.pile_up(new_child)
53
+ return
54
+
55
+ self.children.append(childstack)
56
+
57
+ def __eq__(self, other):
58
+ if isinstance(other, Frame):
59
+ return self._id == other._id
60
+ return False
61
+
62
+ @property
63
+ def display_color(self):
64
+ return r.get_color(self.color_key)
65
+
66
+ def humanize(self, sample_unit, value):
67
+ display_value = value
68
+ if sample_unit == "bytes":
69
+ display_value = sizeof(value)
70
+
71
+ return display_value
72
+
73
+ def __repr__(self) -> str:
74
+ return f"<Frame #{self._id} {self.name}>"
75
+
76
+ def render_detail(self, sample_index: int, sample_unit: str):
77
+ """
78
+ render stacked information
79
+ """
80
+ detail = []
81
+ frame = self
82
+ while frame:
83
+ lines = self.render_one_frame_detail(
84
+ frame, sample_index, sample_unit
85
+ )
86
+ for line in lines:
87
+ detail.append(
88
+ Text.assemble(
89
+ (" ", Style(bgcolor=frame.display_color.rich_color)),
90
+ " ",
91
+ line,
92
+ )
93
+ )
94
+ frame = frame.parent
95
+
96
+ return Text.assemble(*detail)
97
+
98
+ def render_one_frame_detail(
99
+ self, frame, sample_index: int, sample_unit: str
100
+ ):
101
+ raise NotImplementedError
102
+
103
+ @property
104
+ def title(self) -> Text:
105
+ """Full name which will be displayed in the frame detail panel"""
106
+ return Text(self.name)
107
+
108
+ @property
109
+ def color_key(self):
110
+ """Same key will get the same color"""
111
+ return self.name
112
+
113
+ @property
114
+ def display_name(self):
115
+ """The name display on the flamegraph"""
116
+ return self.name
117
+
118
+
119
+ @dataclass
120
+ class SampleType:
121
+ sample_type: str = ""
122
+ sample_unit: str = ""
123
+
124
+
125
+ @dataclass
126
+ class Profile:
127
+ # required
128
+ filename: str
129
+ root_stack: Frame
130
+ highest_lines: int
131
+ # total samples is one top most sample, it's a list that contains all
132
+ # its parents all the way up
133
+ total_sample: int
134
+ sample_types: List[SampleType]
135
+ # int id mapping to Frame
136
+ id_store: Dict[int, Frame]
137
+
138
+ # optional
139
+ default_sample_type_index: int = -1
140
+ period_type: SampleType | None = None
141
+ period: int = 0
142
+ created_at: datetime.datetime | None = None
143
+
144
+ # init by post_init
145
+ lines: List = field(init=False)
146
+
147
+ frameid_to_lineno: Dict[int, int] = field(init=False)
148
+
149
+ # Frame grouped by same name
150
+ name_aggr: Dict[str, List[Frame]] = field(init=False)
151
+
152
+ def __post_init__(self):
153
+ """
154
+ init_lines must be called before render
155
+ """
156
+ t1 = time.time()
157
+ logger.info("start to create lines...")
158
+
159
+ root = self.root_stack
160
+
161
+ lines = [
162
+ [root],
163
+ ]
164
+ frameid_to_lineno = {0: 0}
165
+ current = root.children
166
+ line_no = 1
167
+
168
+ while len(current) > 0:
169
+ line = []
170
+ next_line = []
171
+
172
+ for child in current:
173
+ line.append(child)
174
+ frameid_to_lineno[child._id] = line_no
175
+ next_line.extend(child.children)
176
+
177
+ lines.append(line)
178
+ line_no += 1
179
+ current = next_line
180
+
181
+ t2 = time.time()
182
+ logger.info("create lines done, took %.2f seconds", t2 - t1)
183
+ self.lines = lines
184
+ self.frameid_to_lineno = frameid_to_lineno
185
+
186
+ self.name_aggr = self.get_name_aggr(self.root_stack)
187
+
188
+ def get_name_aggr(
189
+ self, start_frame: Frame, names: Set[str] | None = None
190
+ ) -> Dict[str, List[Frame]]:
191
+ name = start_frame.name
192
+
193
+ result = {}
194
+ if names is None:
195
+ names = set()
196
+ if name not in names:
197
+ result[name] = [start_frame]
198
+
199
+ for child in start_frame.children:
200
+ name_aggr = self.get_name_aggr(child, names | set([name]))
201
+ for key, value in name_aggr.items():
202
+ result.setdefault(key, []).extend(value)
203
+
204
+ return result
@@ -0,0 +1,25 @@
1
+ import logging
2
+
3
+ from flamegraph_textual.exceptions import ProfileParseException
4
+ from flamegraph_textual.parsers.pprof_parser import ProfileParser as PprofParser
5
+ from flamegraph_textual.parsers.stackcollapse_parser import StackCollapseParser
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ ALL_PARSERS = [PprofParser, StackCollapseParser]
11
+
12
+
13
+ def choose_parser(content: bytes):
14
+ for p in ALL_PARSERS:
15
+ if p.validate(content):
16
+ return p
17
+ raise ProfileParseException("Can not match any parser")
18
+
19
+
20
+ def parse(filecontent: bytes, filename):
21
+ parser_cls = choose_parser(filecontent)
22
+ logger.info("Using %s...", parser_cls)
23
+ parser = parser_cls(filename)
24
+ profile = parser.parse(filecontent)
25
+ return profile
@@ -0,0 +1,352 @@
1
+ """
2
+ Parse golang's pprof format into flamegraph_textual models which can be rendered.
3
+
4
+ Ref:
5
+ https://github.com/google/pprof/tree/main/proto
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ import datetime
10
+ import gzip
11
+ import logging
12
+ from typing import Dict, List
13
+
14
+ from rich.text import Text
15
+
16
+ from flamegraph_textual.models import Frame, Profile, SampleType
17
+
18
+ from . import profile_pb2
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class Function:
25
+ id: int = 0
26
+ filename: str = ""
27
+ name: str = ""
28
+ start_line: int = 0
29
+ system_name: str = ""
30
+
31
+
32
+ @dataclass
33
+ class Line:
34
+ line_no: int = 0
35
+ function: Function = field(default_factory=Function)
36
+
37
+
38
+ @dataclass
39
+ class Mapping:
40
+ id: int = 0
41
+ memory_start: int = 0
42
+ memory_limit: int = 0
43
+ file_offset: int = 0
44
+
45
+ filename: str = ""
46
+ build_id: str = ""
47
+
48
+ has_functions: bool | None = None
49
+ has_filenames: bool | None = None
50
+ has_line_numbers: bool | None = None
51
+ has_inline_frames: bool | None = None
52
+
53
+
54
+ @dataclass
55
+ class Location:
56
+ id: int = 0
57
+ mapping: Mapping = field(default_factory=Mapping)
58
+ address: int = 0
59
+ lines: List[Line] = field(default_factory=list)
60
+ is_folded: bool = False
61
+
62
+
63
+ class PprofFrame(Frame):
64
+ def __init__(
65
+ self,
66
+ name,
67
+ _id,
68
+ children=None,
69
+ parent=None,
70
+ values=None,
71
+ root=None,
72
+ line=None,
73
+ mapping=None,
74
+ ) -> None:
75
+ super().__init__(name, _id, children, parent, values, root)
76
+
77
+ parts = self.name.split("/")
78
+ if len(parts) > 1:
79
+ self.golang_package = "/".join(parts[:-1])
80
+ else:
81
+ self.golang_package = "buildin"
82
+
83
+ self.golang_module_function = parts[-1]
84
+
85
+ self.golang_module = self.golang_module_function.split(".")[0]
86
+
87
+ self.mapping_file = ""
88
+ self.line = line
89
+ self.mapping = mapping
90
+
91
+ @property
92
+ def color_key(self):
93
+ return self.golang_module
94
+
95
+ @property
96
+ def display_name(self):
97
+ return self.golang_module_function
98
+
99
+ def render_one_frame_detail(self, frame, sample_index, sample_unit):
100
+ if frame._id == 0: # root
101
+ total = sum([c.values[sample_index] for c in frame.children])
102
+ value = frame.humanize(sample_unit, total)
103
+ if frame.children:
104
+ binary_name = f"Binary: {frame.children[0].mapping.filename}"
105
+ else:
106
+ binary_name = "root"
107
+ detail = f"{binary_name} [b red]{value}[/b red]\n"
108
+ return [Text.from_markup(detail)]
109
+
110
+ value = frame.humanize(sample_unit, frame.values[sample_index])
111
+ line1 = f"{frame.line.function.name}: [b red]{value}[/b red]\n"
112
+
113
+ line2 = (
114
+ f" [grey37]{frame.line.function.filename}, [b]line"
115
+ f" {frame.line.line_no}[/b][/grey37]\n"
116
+ )
117
+ return [Text.from_markup(line1), Text.from_markup(line2)]
118
+
119
+ @property
120
+ def title(self) -> str:
121
+ return self.display_name
122
+
123
+
124
+ def unmarshal(content) -> profile_pb2.Profile:
125
+ if len(content) < 2:
126
+ raise Exception(
127
+ "Profile content length is too short: {} bytes".format(
128
+ len(content)
129
+ )
130
+ )
131
+ is_gzip = content[0] == 31 and content[1] == 139
132
+
133
+ if is_gzip:
134
+ content = gzip.decompress(content)
135
+
136
+ profile = profile_pb2.Profile()
137
+ profile.ParseFromString(content)
138
+
139
+ return profile
140
+
141
+
142
+ class ProfileParser:
143
+ def __init__(self, filename):
144
+ self.filename = filename
145
+ # uniq id
146
+ self.next_id = 0
147
+
148
+ self.root = PprofFrame("root", _id=self.idgenerator())
149
+ # need to set PprofFrame.root for every frame
150
+ self.root.root = self.root
151
+
152
+ # store the pprof's string table
153
+ self._t = []
154
+ # parse cached locations, profile do not need this, so only store
155
+ # them on the parser
156
+ self.locations = []
157
+ self.highest = 0
158
+
159
+ self.id_store: Dict[int, Frame] = {self.root._id: self.root}
160
+
161
+ def idgenerator(self):
162
+ i = self.next_id
163
+ self.next_id += 1
164
+
165
+ return i
166
+
167
+ def s(self, index):
168
+ return self._t[index]
169
+
170
+ def parse_internal_data(self, pbdata):
171
+ self._t = pbdata.string_table
172
+ self.functions = self.parse_functions(pbdata.function)
173
+ self.mappings = self.parse_mapping(pbdata.mapping)
174
+ self.locations = self.parse_location(pbdata.location)
175
+
176
+ def parse(self, binary_data):
177
+ pbdata = unmarshal(binary_data)
178
+ self.parse_internal_data(pbdata)
179
+
180
+ sample_types = self.parse_sample_types(pbdata.sample_type)
181
+
182
+ root = self.root
183
+ root.values = [0] * len(sample_types)
184
+ for pbsample in pbdata.sample:
185
+ child_frame = self.parse_sample(pbsample)
186
+ if not child_frame:
187
+ continue
188
+ root.values = list(map(sum, zip(root.values, child_frame.values)))
189
+ root.pile_up(child_frame)
190
+
191
+ pprof_profile = Profile(
192
+ filename=self.filename,
193
+ root_stack=root,
194
+ highest_lines=self.highest,
195
+ total_sample=len(pbdata.sample),
196
+ sample_types=sample_types,
197
+ id_store=self.id_store,
198
+ )
199
+
200
+ # default is 0, by the doc, default should be the last one
201
+ if pbdata.default_sample_type:
202
+ pprof_profile.default_sample_type_index = (
203
+ pbdata.default_sample_type
204
+ )
205
+
206
+ pprof_profile.created_at = self.parse_created_at(pbdata.time_nanos)
207
+ pprof_profile.period = pbdata.period
208
+ pprof_profile.period_type = self.to_smaple_type(pbdata.period_type)
209
+
210
+ return pprof_profile
211
+
212
+ def parse_sample(self, sample) -> PprofFrame | None:
213
+ values = sample.value
214
+ locations = list(
215
+ reversed([self.locations[i] for i in sample.location_id])
216
+ )
217
+
218
+ my_depth = sum(len(loc.lines) for loc in locations)
219
+ self.highest = max(my_depth, self.highest)
220
+
221
+ current_parent = None
222
+ head = None
223
+ for location in locations:
224
+ for line in location.lines:
225
+ frame = self.line2frame(location, line, values)
226
+
227
+ if current_parent:
228
+ frame.parent = current_parent
229
+ current_parent.children = [frame]
230
+ if not head:
231
+ head = frame
232
+
233
+ current_parent = frame
234
+
235
+ return head
236
+
237
+ def line2frame(self, location: Location, line: Line, values) -> PprofFrame:
238
+ frame = PprofFrame(
239
+ name=line.function.name,
240
+ _id=self.idgenerator(),
241
+ values=values,
242
+ root=self.root,
243
+ mapping=location.mapping,
244
+ )
245
+ frame.line = line
246
+ self.id_store[frame._id] = frame
247
+ return frame
248
+
249
+ def parse_location(self, pblocations):
250
+ parsed_locations = {}
251
+ for pl in pblocations:
252
+ loc = Location()
253
+ loc.id = pl.id
254
+ loc.mapping = self.mappings[pl.mapping_id]
255
+ loc.address = pl.address
256
+ loc.lines = self.parse_line(pl.line)
257
+ loc.is_folded = pl.is_folded
258
+ parsed_locations[loc.id] = loc
259
+
260
+ return parsed_locations
261
+
262
+ def parse_mapping(self, pbmappings):
263
+ mappings = {}
264
+ for pbm in pbmappings:
265
+ m = Mapping()
266
+ m.id = pbm.id
267
+ m.memory_start = pbm.memory_start
268
+ m.memory_limit = pbm.memory_limit
269
+ m.file_offset = pbm.file_offset
270
+ m.filename = self.s(pbm.filename)
271
+ m.build_id = self.s(pbm.build_id)
272
+ m.has_functions = pbm.has_functions
273
+ m.has_filenames = pbm.has_filenames
274
+ m.has_line_numbers = pbm.has_line_numbers
275
+ m.has_inline_frames = pbm.has_inline_frames
276
+
277
+ mappings[m.id] = m
278
+
279
+ return mappings
280
+
281
+ def parse_line(self, pblines) -> List[Line]:
282
+ lines = []
283
+ for pl in reversed(pblines):
284
+ line = Line(
285
+ line_no=pl.line,
286
+ function=self.functions[pl.function_id],
287
+ )
288
+ lines.append(line)
289
+ return lines
290
+
291
+ def parse_functions(self, pfs):
292
+ functions = {}
293
+ for pf in pfs:
294
+ functions[pf.id] = Function(
295
+ id=pf.id,
296
+ filename=self.s(pf.filename),
297
+ name=self.s(pf.name),
298
+ system_name=self.s(pf.system_name),
299
+ start_line=pf.start_line,
300
+ )
301
+ return functions
302
+
303
+ def parse_created_at(self, time_nanos):
304
+ date = datetime.datetime.fromtimestamp(
305
+ time_nanos / 1e9, tz=datetime.timezone.utc
306
+ )
307
+ return date
308
+
309
+ def parse_sample_types(self, sample_types):
310
+ result = []
311
+ for st in sample_types:
312
+ result.append(self.to_smaple_type(st))
313
+
314
+ return result
315
+
316
+ def to_smaple_type(self, st):
317
+ return SampleType(self.s(st.type), self.s(st.unit))
318
+
319
+ @classmethod
320
+ def validate(cls, content: bytes) -> bool:
321
+ try:
322
+ unmarshal(content)
323
+ except: # noqa E722
324
+ logger.info("Error when parse content as Pprof")
325
+ return False
326
+ return True
327
+
328
+
329
+ def get_frame_tree(root_frame):
330
+ """
331
+ only for testing and debugging
332
+ """
333
+
334
+ def _get_child(frame):
335
+ return {c.name: _get_child(c) for c in frame.children}
336
+
337
+ return {"root": _get_child(root_frame)}
338
+
339
+
340
+ def parse_profile(binary_data, filename):
341
+ parser = ProfileParser(filename)
342
+ profile = parser.parse(binary_data)
343
+
344
+ # import ipdb; ipdb.set_trace()
345
+ return profile
346
+
347
+
348
+ if __name__ == "__main__":
349
+ with open("tests/pprof_data/goroutine.out", "rb") as f:
350
+ content = f.read()
351
+
352
+ parse_profile(content, "abc")