feat(devtools): add filtering functionality in the profiler

Allow to filter using a simple DSL:

```
search := query*
query := `source:` \w+ | `duration:` [>|<|=|>=|<=]\d+ (ms)?
```
This commit is contained in:
mgechev 2021-03-11 19:57:01 -08:00 committed by Minko Gechev
parent 0b24fe08f5
commit ff99b9bb03
9 changed files with 384 additions and 26 deletions

View file

@ -0,0 +1,194 @@
import { createFilter, parseFilter } from './filter';
describe('filtering', () => {
describe('parsing', () => {
it('should parse filters', () => {
const result = parseFilter('');
expect(result).toEqual([]);
});
it('should return empty filter if the query is invalid', () => {
const result = parseFilter('foobar');
expect(result.length).toBe(0);
});
it('should return proper result with duration', () => {
const result = parseFilter('duration : =2');
expect(result.length).toBe(1);
expect(result[0][0]).toBe('duration');
expect(result[0][1]).toEqual(['=', 2]);
});
it('should return proper result with source', () => {
const result = parseFilter('source : foobar');
expect(result.length).toBe(1);
expect(result[0][0]).toBe('source');
expect(result[0][1]).toEqual('foobar');
});
it('should return proper result with composite filters', () => {
const result = parseFilter('source : foobar baz duration: >= 4200');
expect(result.length).toBe(2);
expect(result[0][0]).toBe('source');
expect(result[0][1]).toEqual('foobar baz');
expect(result[1][0]).toBe('duration');
expect(result[1][1]).toEqual(['>=', 4200]);
});
it('should ignore invalid operators', () => {
const result1 = parseFilter('sorce : foobar baz duration: >= 4200');
expect(result1.length).toBe(1);
expect(result1[0][0]).toBe('duration');
expect(result1[0][1]).toEqual(['>=', 4200]);
const result2 = parseFilter('sorce : foobar baz dration: >= 4200');
expect(result2.length).toBe(0);
});
it('should consider decimal values in durations queries with a ms suffix', () => {
const result = parseFilter('source: foobar duration: >=42.42ms');
expect(result.length).toBe(2);
expect(result[0][0]).toBe('source');
expect(result[0][1]).toEqual('foobar');
expect(result[1][0]).toBe('duration');
expect(result[1][1]).toEqual(['>=', 42.42]);
});
});
describe('filtering', () => {
it('should filter results with a source query', () => {
const filter = createFilter('source:click');
expect(
filter({
frame: {
directives: [],
duration: 10,
source: 'click',
},
style: {},
toolTip: '',
})
).toBeTrue();
expect(
filter({
frame: {
directives: [],
duration: 10,
source: 'mouseenter',
},
style: {},
toolTip: '',
})
).toBeFalse();
});
it('should filter results with a duration query', () => {
const filter1 = createFilter('duration:>10ms');
expect(
filter1({
frame: {
directives: [],
duration: 10,
source: 'click',
},
style: {},
toolTip: '',
})
).toBeFalse();
expect(
filter1({
frame: {
directives: [],
duration: 15,
source: 'mouseenter',
},
style: {},
toolTip: '',
})
).toBeTrue();
const filter2 = createFilter('duration:=10ms');
expect(
filter2({
frame: {
directives: [],
duration: 11,
source: 'mouseenter',
},
style: {},
toolTip: '',
})
).toBeFalse();
expect(
filter2({
frame: {
directives: [],
duration: 10,
source: 'mouseenter',
},
style: {},
toolTip: '',
})
).toBeTrue();
});
it('should work with composite selectors', () => {
const filter = createFilter('duration:>10ms source: click');
expect(
filter({
frame: {
directives: [],
duration: 10,
source: 'click',
},
style: {},
toolTip: '',
})
).toBeFalse();
expect(
filter({
frame: {
directives: [],
duration: 15,
source: 'mouseenter',
},
style: {},
toolTip: '',
})
).toBeFalse();
expect(
filter({
frame: {
directives: [],
duration: 15,
source: 'click',
},
style: {},
toolTip: '',
})
).toBeTrue();
});
it('should work with invalid arguments', () => {
const filter = createFilter('duration:>ms');
expect(
filter({
frame: {
directives: [],
duration: 15,
source: 'click',
},
style: {},
toolTip: '',
})
).toBeTrue();
});
});
});

View file

@ -0,0 +1,123 @@
import { ProfilerFrame } from 'protocol';
import { GraphNode } from './record-formatter/record-formatter';
export type Filter = (nodes: GraphNode) => boolean;
export const noopFilter = (_: GraphNode) => true;
interface Query<Arguments = unknown> {
readonly name: QueryType;
parseArguments(args: string[]): Arguments | undefined;
apply(node: ProfilerFrame, args: Arguments): boolean;
}
type Operator = '>' | '<' | '=' | '<=' | '>=';
const ops: { [operator in Operator]: (a: number, b: number) => boolean } = {
'>'(a: number, b: number): boolean {
return a > b;
},
'<'(a: number, b: number): boolean {
return a < b;
},
'='(a: number, b: number): boolean {
return a === b;
},
'<='(a: number, b: number): boolean {
return a <= b;
},
'>='(a: number, b: number): boolean {
return a >= b;
},
};
type DurationArgument = [Operator, number];
type SourceArgument = string;
const enum QueryType {
Duration = 'duration',
Source = 'source',
}
type QueryArguments = DurationArgument | SourceArgument;
const operatorRe = /^(>=|<=|=|<|>|)/;
class DurationQuery implements Query<DurationArgument> {
readonly name = QueryType.Duration;
parseArguments([arg]: string[]): DurationArgument | undefined {
arg.trim();
const operator = (arg.match(operatorRe) ?? [null])[0];
if (!operator) {
return undefined;
}
const num = parseFloat(arg.replace(operatorRe, '').trim());
if (isNaN(num)) {
return undefined;
}
return [operator as Operator, num] as DurationArgument;
}
apply(node: ProfilerFrame, args: DurationArgument): boolean {
return ops[args[0]](node.duration, args[1]);
}
}
class SourceQuery implements Query<SourceArgument> {
readonly name = QueryType.Source;
parseArguments([arg]: string[]): SourceArgument {
return arg;
}
apply(node: ProfilerFrame, args: SourceArgument): boolean {
return node.source.indexOf(args) >= 0;
}
}
const queryMap: { [query in QueryType]: Query } = [new DurationQuery(), new SourceQuery()].reduce((map, query) => {
map[query.name] = query;
return map;
}, {} as { [query in QueryType]: Query });
const queryRe = new RegExp(`(${QueryType.Duration}|${QueryType.Source})$`, 'g');
export const parseFilter = (query: string): [QueryType, QueryArguments][] => {
const parts = query.split(':').map((part) => part.trim());
if (parts.length <= 1) {
return [];
}
const result: [QueryType, QueryArguments][] = [];
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!queryRe.test(part)) {
continue;
}
const match = part.match(/(\w+)$/);
if (!match) {
continue;
}
const operator = queryMap[match[0] as QueryType];
if (!operator) {
continue;
}
const operandString = parts[i + 1].replace(queryRe, '').trim();
const operand = operator.parseArguments([operandString]) as QueryArguments;
if (!operand) {
continue;
}
result.push([operator.name, operand]);
}
return result;
};
export const createFilter = (query: string) => {
const queries = parseFilter(query);
return (frame: GraphNode) => {
return queries.every(([queryName, args]) => {
const currentQuery = queryMap[queryName];
if (!currentQuery) {
return true;
}
return currentQuery.apply(frame.frame, args);
});
};
};

View file

@ -13,6 +13,7 @@ export interface AppEntry<T> {
export interface GraphNode {
toolTip: string;
style: any;
frame: ProfilerFrame;
}
export abstract class RecordFormatter<T> {

View file

@ -1,17 +1,29 @@
<div class="controls">
<mat-form-field *ngIf="record">
<mat-select [value]="visualizationMode" (selectionChange)="this.changeVisualizationMode.emit($event.value)">
<mat-option [value]="flameGraphMode">
Flame graph
</mat-option>
<mat-option [value]="treeMapMode">
Tree map
</mat-option>
<mat-option [value]="barGraphMode">
Bar chart
</mat-option>
</mat-select>
</mat-form-field>
<div class="visual-controls">
<mat-form-field>
<mat-label>Filter</mat-label>
<input
matInput
class="filter-input"
(keyup)="filter.emit($event.target.value)"
placeholder="duration: >30 source: click"
/>
</mat-form-field>
<mat-form-field [class.hidden]="!record">
<mat-select [value]="visualizationMode" (selectionChange)="this.changeVisualizationMode.emit($event.value)">
<mat-option [value]="flameGraphMode">
Flame graph
</mat-option>
<mat-option [value]="treeMapMode">
Tree map
</mat-option>
<mat-option [value]="barGraphMode">
Bar chart
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div
class="details"

View file

@ -2,6 +2,19 @@
height: 60px;
}
.visual-controls {
display: flex;
flex-direction: column;
height: 80px;
justify-content: space-around;
margin-right: 25px;
width: 150px;
}
.hidden {
visibility: hidden;
}
.controls {
display: flex;
white-space: nowrap;

View file

@ -17,6 +17,7 @@ export class TimelineControlsComponent {
@Output() changeVisualizationMode = new EventEmitter<VisualizationMode>();
@Output() exportProfile = new EventEmitter<void>();
@Output() toggleChangeDetection = new EventEmitter<boolean>();
@Output() filter = new EventEmitter<string>();
flameGraphMode = VisualizationMode.FlameGraph;
treeMapMode = VisualizationMode.TreeMap;

View file

@ -2,7 +2,7 @@
<p class="info" *ngIf="!hasFrames && visualizing">There's no information to show.</p>
<div style="margin: 10px; height: 100%">
<div style="margin: 10px; height: 100%;">
<ng-timeline-controls
[class.hidden]="!hasFrames"
[record]="frame"
@ -14,6 +14,7 @@
(changeVisualizationMode)="visualizationMode = $event"
(exportProfile)="exportProfile.emit($event)"
(toggleChangeDetection)="changeDetection = $event"
(filter)="setFilter($event)"
></ng-timeline-controls>
<ng-frame-selector

View file

@ -2,8 +2,9 @@ import { Component, EventEmitter, Input, Output, OnDestroy } from '@angular/core
import { ProfilerFrame } from 'protocol';
import { GraphNode } from './record-formatter/record-formatter';
import { Observable, Subscription, BehaviorSubject } from 'rxjs';
import { share } from 'rxjs/operators';
import { map, share } from 'rxjs/operators';
import { mergeFrames } from './record-formatter/frame-merger';
import { createFilter, Filter, noopFilter } from './filter';
export enum VisualizationMode {
FlameGraph,
@ -24,6 +25,7 @@ export class TimelineComponent implements OnDestroy {
this._subscription.unsubscribe();
}
this._allRecords = [];
this._filtered = [];
this._maxDuration = -Infinity;
this._subscription = data.subscribe({
next: (frames: ProfilerFrame[]): void => {
@ -39,19 +41,26 @@ export class TimelineComponent implements OnDestroy {
changeDetection = false;
frame: ProfilerFrame | null = null;
private _filter: Filter = noopFilter;
private _maxDuration = -Infinity;
private _subscription: Subscription;
private _allRecords: ProfilerFrame[] = [];
private _filtered: GraphNode[] = [];
private _graphDataSubject = new BehaviorSubject<GraphNode[]>([]);
visualizing = false;
graphData$ = this._graphDataSubject.asObservable().pipe(share());
graphData$ = this._graphDataSubject.pipe(
share(),
map((nodes) => {
return (this._filtered = nodes.filter((node) => this._filter(node)));
})
);
selectFrames({ indexes }: { indexes: number[] }): void {
this.frame = mergeFrames(indexes.map((index) => this._allRecords[index]));
this.frame = mergeFrames(indexes.map((index) => this._filtered[index].frame));
}
get hasFrames(): boolean {
return this._allRecords.length > 0;
return this._graphDataSubject.value.length > 0;
}
estimateFrameRate(timeSpent: number): number {
@ -59,6 +68,11 @@ export class TimelineComponent implements OnDestroy {
return Math.floor(60 / 2 ** multiplier);
}
setFilter(filter: string): void {
this._filter = createFilter(filter);
this._graphDataSubject.next(this._graphDataSubject.value);
}
getColorByFrameRate(framerate: number): string {
if (framerate >= 60) {
return '#d6f0d1';
@ -104,13 +118,10 @@ export class TimelineComponent implements OnDestroy {
return this._allRecords.map((r) => this._getBarStyles(r, multiplicationFactor));
}
private _getBarStyles(
record: ProfilerFrame,
multiplicationFactor: number
): { style: { [key: string]: string }; toolTip: string } {
const height = record.duration * multiplicationFactor;
private _getBarStyles(frame: ProfilerFrame, multiplicationFactor: number): GraphNode {
const height = frame.duration * multiplicationFactor;
const colorPercentage = Math.max(10, Math.round((height / MAX_HEIGHT) * 100));
const backgroundColor = this.getColorByFrameRate(this.estimateFrameRate(record.duration));
const backgroundColor = this.getColorByFrameRate(this.estimateFrameRate(frame.duration));
const style = {
'background-image': `-webkit-linear-gradient(bottom, ${backgroundColor} ${colorPercentage}%, transparent ${colorPercentage}%)`,
@ -119,7 +130,7 @@ export class TimelineComponent implements OnDestroy {
width: '25px',
height: '50px',
};
const toolTip = `${record.source} TimeSpent: ${record.duration.toFixed(3)}ms`;
return { style, toolTip };
const toolTip = `${frame.source} TimeSpent: ${frame.duration.toFixed(3)}ms`;
return { style, toolTip, frame };
}
}

View file

@ -17,6 +17,7 @@ import { RecordingDialogComponent } from './recording-modal/recording-dialog/rec
import { RecordingModalComponent } from './recording-modal/recording-modal.component';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
@NgModule({
declarations: [
@ -38,6 +39,7 @@ import { MatDialogModule } from '@angular/material/dialog';
MatTooltipModule,
MatIconModule,
MatCardModule,
MatInputModule,
NgxFlamegraphModule,
MatSelectModule,
],