forked from grafana/grafana
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathdataTransform.ts
424 lines (352 loc) · 12.6 KB
/
dataTransform.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
import {
createTheme,
DataFrame,
DisplayProcessor,
Field,
FieldType,
getDisplayProcessor,
GrafanaTheme2,
} from '@grafana/data';
import { SampleUnit } from '../types';
import { mergeParentSubtrees, mergeSubtrees } from './treeTransforms';
export type LevelItem = {
// Offset from the start of the level.
start: number;
// Value here can be different from a value of items in the data frame as for callers tree in sandwich view we have
// to trim the value to correspond only to the part used by the children in the subtree.
// In case of diff profile this is actually left + right value.
value: number;
// Only exists for diff profiles.
valueRight?: number;
// Index into the data frame. It is an array because for sandwich views we may be merging multiple items into single
// node.
itemIndexes: number[];
children: LevelItem[];
level: number;
parents?: LevelItem[];
};
export type CollapseConfig = {
items: LevelItem[];
collapsed: boolean;
};
/**
* Convert data frame with nested set format into array of level. This is mainly done for compatibility with current
* rendering code.
*/
export function nestedSetToLevels(
container: FlameGraphDataContainer,
options?: Options
): [LevelItem[][], Record<string, LevelItem[]>, CollapsedMap] {
const levels: LevelItem[][] = [];
let offset = 0;
let parent: LevelItem | undefined = undefined;
const uniqueLabels: Record<string, LevelItem[]> = {};
for (let i = 0; i < container.data.length; i++) {
const currentLevel = container.getLevel(i);
const prevLevel = i > 0 ? container.getLevel(i - 1) : undefined;
levels[currentLevel] = levels[currentLevel] || [];
if (prevLevel && prevLevel >= currentLevel) {
// We are going down a level or staying at the same level, so we are adding a sibling to the last item in a level.
// So we have to compute the correct offset based on the last sibling.
const lastSibling = levels[currentLevel][levels[currentLevel].length - 1];
offset =
lastSibling.start +
container.getValue(lastSibling.itemIndexes[0]) +
container.getValueRight(lastSibling.itemIndexes[0]);
// we assume there is always a single root node so lastSibling should always have a parent.
// Also it has to have the same parent because of how the items are ordered.
parent = lastSibling.parents![0];
}
const newItem: LevelItem = {
itemIndexes: [i],
value: container.getValue(i) + container.getValueRight(i),
valueRight: container.isDiffFlamegraph() ? container.getValueRight(i) : undefined,
start: offset,
parents: parent && [parent],
children: [],
level: currentLevel,
};
if (uniqueLabels[container.getLabel(i)]) {
uniqueLabels[container.getLabel(i)].push(newItem);
} else {
uniqueLabels[container.getLabel(i)] = [newItem];
}
if (parent) {
parent.children.push(newItem);
}
parent = newItem;
levels[currentLevel].push(newItem);
}
const collapsedMapContainer = new CollapsedMapBuilder(options?.collapsingThreshold);
if (options?.collapsing) {
// We collapse similar items here, where it seems like parent and child are the same thing and so the distinction
// isn't that important. We create a map of items that should be collapsed together. We need to do it with complete
// tree as we need to know how many children an item has to know if we can collapse it.
collapsedMapContainer.addTree(levels[0][0]);
}
return [levels, uniqueLabels, collapsedMapContainer.getCollapsedMap()];
}
/**
* Small wrapper around the map of items that should be visually collapsed in the flame graph. Reason this is a wrapper
* is that we want to make sure that when this is in the state we don't update the map directly but create a new map
* and to have a place for the methods to collapse/expand either single item or all the items.
*/
export class CollapsedMap {
// The levelItem used as a key is the item that will always be rendered in the flame graph. The config.items are all
// the items that are in the group and if the config.collapsed is true they will be hidden.
private map: Map<LevelItem, CollapseConfig> = new Map();
constructor(map?: Map<LevelItem, CollapseConfig>) {
this.map = map || new Map();
}
get(item: LevelItem) {
return this.map.get(item);
}
keys() {
return this.map.keys();
}
values() {
return this.map.values();
}
size() {
return this.map.size;
}
setCollapsedStatus(item: LevelItem, collapsed: boolean) {
const newMap = new Map(this.map);
const collapsedConfig = this.map.get(item)!;
const newConfig = { ...collapsedConfig, collapsed };
for (const item of collapsedConfig.items) {
newMap.set(item, newConfig);
}
return new CollapsedMap(newMap);
}
setAllCollapsedStatus(collapsed: boolean) {
const newMap = new Map(this.map);
for (const item of this.map.keys()) {
const collapsedConfig = this.map.get(item)!;
const newConfig = { ...collapsedConfig, collapsed };
newMap.set(item, newConfig);
}
return new CollapsedMap(newMap);
}
}
/**
* Similar to CollapsedMap but this one is mutable and used during transformation of the dataFrame data into structure
* we use for rendering. This should not be passed to the React components.
*/
export class CollapsedMapBuilder {
private map = new Map();
private threshold = 0.99;
constructor(threshold?: number) {
if (threshold !== undefined) {
this.threshold = threshold;
}
}
addTree(root: LevelItem) {
const stack = [root];
while (stack.length) {
const current = stack.shift()!;
if (current.parents?.length) {
this.addItem(current, current.parents[0]);
}
if (current.children.length) {
stack.unshift(...current.children);
}
}
}
// The heuristics here is pretty simple right now. Just check if it's single child and if we are within threshold.
// We assume items with small self just aren't too important while we cannot really collapse items with siblings
// as it's not clear what to do with said sibling.
addItem(item: LevelItem, parent?: LevelItem) {
if (parent && item.value > parent.value * this.threshold && parent.children.length === 1) {
if (this.map.has(parent)) {
const config = this.map.get(parent)!;
this.map.set(item, config);
config.items.push(item);
} else {
const config = { items: [parent, item], collapsed: true };
this.map.set(parent, config);
this.map.set(item, config);
}
}
}
getCollapsedMap() {
return new CollapsedMap(this.map);
}
}
export function getMessageCheckFieldsResult(wrongFields: CheckFieldsResult) {
if (wrongFields.missingFields.length) {
return `Data is missing fields: ${wrongFields.missingFields.join(', ')}`;
}
if (wrongFields.wrongTypeFields.length) {
return `Data has fields of wrong type: ${wrongFields.wrongTypeFields
.map((f) => `${f.name} has type ${f.type} but should be ${f.expectedTypes.join(' or ')}`)
.join(', ')}`;
}
return '';
}
export type CheckFieldsResult = {
wrongTypeFields: Array<{ name: string; expectedTypes: FieldType[]; type: FieldType }>;
missingFields: string[];
};
export function checkFields(data: DataFrame): CheckFieldsResult | undefined {
const fields: Array<[string, FieldType[]]> = [
['label', [FieldType.string, FieldType.enum]],
['level', [FieldType.number]],
['value', [FieldType.number]],
['self', [FieldType.number]],
];
const missingFields = [];
const wrongTypeFields = [];
for (const field of fields) {
const [name, types] = field;
const frameField = data.fields.find((f) => f.name === name);
if (!frameField) {
missingFields.push(name);
continue;
}
if (!types.includes(frameField.type)) {
wrongTypeFields.push({ name, expectedTypes: types, type: frameField.type });
}
}
if (missingFields.length > 0 || wrongTypeFields.length > 0) {
return {
wrongTypeFields,
missingFields,
};
}
return undefined;
}
export type Options = {
collapsing: boolean;
collapsingThreshold?: number;
};
export class FlameGraphDataContainer {
data: DataFrame;
options: Options;
labelField: Field;
levelField: Field;
valueField: Field;
selfField: Field;
// Optional fields for diff view
valueRightField?: Field;
selfRightField?: Field;
labelDisplayProcessor: DisplayProcessor;
valueDisplayProcessor: DisplayProcessor;
uniqueLabels: string[];
private levels: LevelItem[][] | undefined;
private uniqueLabelsMap: Record<string, LevelItem[]> | undefined;
private collapsedMap: CollapsedMap | undefined;
constructor(data: DataFrame, options: Options, theme: GrafanaTheme2 = createTheme()) {
this.data = data;
this.options = options;
const wrongFields = checkFields(data);
if (wrongFields) {
throw new Error(getMessageCheckFieldsResult(wrongFields));
}
this.labelField = data.fields.find((f) => f.name === 'label')!;
this.levelField = data.fields.find((f) => f.name === 'level')!;
this.valueField = data.fields.find((f) => f.name === 'value')!;
this.selfField = data.fields.find((f) => f.name === 'self')!;
this.valueRightField = data.fields.find((f) => f.name === 'valueRight')!;
this.selfRightField = data.fields.find((f) => f.name === 'selfRight')!;
if ((this.valueField || this.selfField) && !(this.valueField && this.selfField)) {
throw new Error(
'Malformed dataFrame: both valueRight and selfRight has to be present if one of them is present.'
);
}
const enumConfig = this.labelField?.config?.type?.enum;
// Label can actually be an enum field so depending on that we have to access it through display processor. This is
// both a backward compatibility but also to allow using a simple dataFrame without enum config. This would allow
// users to use this panel with correct query from data sources that do not return profiles natively.
if (enumConfig) {
this.labelDisplayProcessor = getDisplayProcessor({ field: this.labelField, theme });
this.uniqueLabels = enumConfig.text || [];
} else {
this.labelDisplayProcessor = (value) => ({
text: value + '',
numeric: 0,
});
this.uniqueLabels = [...new Set<string>(this.labelField.values)];
}
this.valueDisplayProcessor = getDisplayProcessor({
field: this.valueField,
theme,
});
}
isDiffFlamegraph() {
return Boolean(this.valueRightField && this.selfRightField);
}
getLabel(index: number) {
return this.labelDisplayProcessor(this.labelField.values[index]).text;
}
getLevel(index: number) {
return this.levelField.values[index];
}
getValue(index: number | number[]) {
return fieldAccessor(this.valueField, index);
}
getValueRight(index: number | number[]) {
return fieldAccessor(this.valueRightField, index);
}
getSelf(index: number | number[]) {
return fieldAccessor(this.selfField, index);
}
getSelfRight(index: number | number[]) {
return fieldAccessor(this.selfRightField, index);
}
getSelfDisplay(index: number | number[]) {
return this.valueDisplayProcessor(this.getSelf(index));
}
getUniqueLabels() {
return this.uniqueLabels;
}
getUnitTitle() {
switch (this.valueField.config.unit) {
case SampleUnit.Bytes:
return 'RAM';
case SampleUnit.Nanoseconds:
return 'Time';
}
return 'Count';
}
getLevels() {
this.initLevels();
return this.levels!;
}
getSandwichLevels(label: string): [LevelItem[][], LevelItem[][]] {
const nodes = this.getNodesWithLabel(label);
if (!nodes?.length) {
return [[], []];
}
const callers = mergeParentSubtrees(nodes, this);
const callees = mergeSubtrees(nodes, this);
return [callers, callees];
}
getNodesWithLabel(label: string) {
this.initLevels();
return this.uniqueLabelsMap![label];
}
getCollapsedMap() {
this.initLevels();
return this.collapsedMap!;
}
private initLevels() {
if (!this.levels) {
const [levels, uniqueLabelsMap, collapsedMap] = nestedSetToLevels(this, this.options);
this.levels = levels;
this.uniqueLabelsMap = uniqueLabelsMap;
this.collapsedMap = collapsedMap;
}
}
}
// Access field value with either single index or array of indexes. This is needed as we sometimes merge multiple
// into one, and we want to access aggregated values.
function fieldAccessor(field: Field | undefined, index: number | number[]) {
if (!field) {
return 0;
}
let indexArray: number[] = typeof index === 'number' ? [index] : index;
return indexArray.reduce((acc, index) => {
return acc + field.values[index];
}, 0);
}