blob: 1dd2432289c3d9b51c631ccb9d01e886b0cf78b6 [file] [log] [blame] [edit]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {time, Time, TimeSpan} from '../../base/time';
import {DetailsShell} from '../../widgets/details_shell';
import {Timestamp} from '../../components/widgets/timestamp';
import {Engine} from '../../trace_processor/engine';
import {LONG, NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
import {
escapeQuery,
escapeSearchQuery,
escapeRegexQuery,
} from '../../trace_processor/query_utils';
import {Select} from '../../widgets/select';
import {
MultiSelectDiff,
MultiSelectOption,
PopupMultiSelect,
} from '../../widgets/multiselect';
import {PopupPosition} from '../../widgets/popup';
import {Button} from '../../widgets/button';
import {TextInput} from '../../widgets/text_input';
import {
Grid,
GridColumn,
GridRow,
GridHeaderCell,
GridCell,
} from '../../widgets/grid';
import {classNames} from '../../base/classnames';
import {TagInput} from '../../widgets/tag_input';
import {Store} from '../../base/store';
import {Trace} from '../../public/trace';
import {Icons} from '../../base/semantic_icons';
import {SerialTaskQueue, QuerySlot} from '../../base/query_slot';
const ROW_H = 24;
export interface LogFilteringCriteria {
readonly minimumLevel: number;
readonly tags: string[];
readonly isTagRegex?: boolean;
readonly textEntry: string;
readonly hideNonMatching: boolean;
readonly machineExcludeList: number[];
}
export interface LogPanelCache {
readonly uniqueMachineIds: number[];
}
export interface LogPanelAttrs {
readonly cache: LogPanelCache;
readonly filterStore: Store<LogFilteringCriteria>;
readonly trace: Trace;
}
interface Pagination {
readonly offset: number;
readonly count: number;
}
interface LogEntries {
readonly offset: number;
readonly machineIds: number[];
readonly timestamps: time[];
readonly pids: bigint[];
readonly tids: bigint[];
readonly priorities: number[];
readonly tags: string[];
readonly messages: string[];
readonly isHighlighted: boolean[];
readonly processName: string[];
readonly totalEvents: number; // Count of the total number of events within this window
}
export class LogPanel implements m.ClassComponent<LogPanelAttrs> {
private readonly trace: Trace;
private readonly executor = new SerialTaskQueue();
private readonly viewQuery = new QuerySlot<AsyncDisposable>(this.executor);
private readonly entriesQuery = new QuerySlot<LogEntries>(this.executor);
private pagination: Pagination = {
offset: 0,
count: 0,
};
constructor({attrs}: m.CVnode<LogPanelAttrs>) {
this.trace = attrs.trace;
}
onremove() {
this.viewQuery.dispose();
this.entriesQuery.dispose();
}
view({attrs}: m.CVnode<LogPanelAttrs>) {
const visibleSpan = attrs.trace.timeline.visibleWindow.toTimeSpan();
const filters = attrs.filterStore.state;
const pagination = this.pagination;
const engine = attrs.trace.engine;
// Query 1: Create the filtered_logs table (no staleOn = always-fresh)
const viewResult = this.viewQuery.use({
key: {filters},
queryFn: () => updateLogView(engine, filters),
});
// Query 2: Read from the table (staleOn=['pagination'] for smooth scrolling)
const entriesResult = this.entriesQuery.use({
key: {
filters,
viewport: {start: visibleSpan.start, end: visibleSpan.end},
pagination,
},
retainOn: ['pagination', 'viewport'],
queryFn: () => updateLogEntries(engine, visibleSpan, pagination),
enabled: !!viewResult.data,
});
const entries = entriesResult.data;
const totalEvents = entries?.totalEvents ?? 0;
return m(
DetailsShell,
{
title: 'Android Logs',
description: `Total messages: ${totalEvents}`,
buttons: m(LogsFilters, {
trace: attrs.trace,
cache: attrs.cache,
store: attrs.filterStore,
}),
},
this.renderGrid(attrs.trace, entries, attrs.cache),
);
}
private renderGrid(
trace: Trace,
entries: LogEntries | undefined,
cache: LogPanelCache,
) {
if (entries) {
const hasMachineIds = cache.uniqueMachineIds.length > 1;
const hasProcessNames =
entries.processName.filter((name) => name).length > 0;
const columns: GridColumn[] = [
...(hasMachineIds
? [{key: 'machine', header: m(GridHeaderCell, 'Machine')}]
: []),
{key: 'timestamp', header: m(GridHeaderCell, 'Timestamp')},
{key: 'pid', header: m(GridHeaderCell, 'PID')},
{key: 'tid', header: m(GridHeaderCell, 'TID')},
{key: 'level', header: m(GridHeaderCell, 'Level')},
...(hasProcessNames
? [{key: 'process', header: m(GridHeaderCell, 'Process')}]
: []),
{key: 'tag', header: m(GridHeaderCell, 'Tag')},
{
key: 'message',
// Allow the initial width of the message column to expand as needed.
maxInitialWidthPx: Infinity,
header: m(GridHeaderCell, 'Message'),
},
];
return m(Grid, {
className: 'pf-logs-panel',
columns,
rowData: {
data: this.renderRows(entries, hasMachineIds, hasProcessNames),
total: entries?.totalEvents ?? 0,
offset: entries?.offset ?? 0,
onLoadData: (offset, count) => {
this.pagination = {offset, count};
m.redraw();
},
},
virtualization: {
rowHeightPx: ROW_H,
},
fillHeight: true,
onRowHover: (rowIndex) => {
// Calculate the actual row index from virtualization offset
const actualIndex = rowIndex - (entries?.offset ?? 0);
const timestamp = entries?.timestamps[actualIndex];
if (timestamp !== undefined) {
trace.timeline.hoverCursorTimestamp = timestamp;
}
},
onRowOut: () => {
trace.timeline.hoverCursorTimestamp = undefined;
},
});
} else {
return null;
}
}
private renderRows(
entries: LogEntries,
hasMachineIds: boolean | undefined,
hasProcessNames: boolean | undefined,
): ReadonlyArray<GridRow> {
const trace = this.trace;
const machineIds = entries.machineIds;
const timestamps = entries.timestamps;
const pids = entries.pids;
const tids = entries.tids;
const priorities = entries.priorities;
const tags = entries.tags;
const messages = entries.messages;
const processNames = entries.processName;
const rows: GridRow[] = [];
for (let i = 0; i < entries.timestamps.length; i++) {
const priority = priorities[i];
const priorityLetter = LOG_PRIORITIES[priority][0];
const ts = timestamps[i];
const priorityClass = `pf-logs-panel__row--${classForPriority(priority)}`;
const isHighlighted = entries.isHighlighted[i];
const className = classNames(
priorityClass,
isHighlighted && 'pf-logs-panel__row--highlighted',
);
const row = [
hasMachineIds &&
m(GridCell, {className, align: 'right'}, machineIds[i]),
m(GridCell, {className}, m(Timestamp, {trace, ts})),
m(GridCell, {className, align: 'right'}, String(pids[i])),
m(GridCell, {className, align: 'right'}, String(tids[i])),
m(GridCell, {className}, priorityLetter || '?'),
hasProcessNames && m(GridCell, {className}, processNames[i]),
m(GridCell, {className}, tags[i]),
m(GridCell, {className}, messages[i]),
].filter(Boolean);
rows.push(row);
}
return rows;
}
}
function classForPriority(priority: number) {
switch (priority) {
case 2:
return 'verbose';
case 3:
return 'debug';
case 4:
return 'info';
case 5:
return 'warn';
case 6:
return 'error';
case 7:
return 'fatal';
default:
return undefined;
}
}
export const LOG_PRIORITIES = [
'-',
'-',
'Verbose',
'Debug',
'Info',
'Warn',
'Error',
'Fatal',
];
const IGNORED_STATES = 2;
interface LogPriorityWidgetAttrs {
readonly trace: Trace;
readonly options: string[];
readonly selectedIndex: number;
readonly onSelect: (id: number) => void;
}
class LogPriorityWidget implements m.ClassComponent<LogPriorityWidgetAttrs> {
view(vnode: m.Vnode<LogPriorityWidgetAttrs>) {
const attrs = vnode.attrs;
const optionComponents = [];
for (let i = IGNORED_STATES; i < attrs.options.length; i++) {
const selected = i === attrs.selectedIndex;
optionComponents.push(
m('option', {value: i, selected}, attrs.options[i]),
);
}
return m(
Select,
{
onchange: (e: Event) => {
const selectionValue = (e.target as HTMLSelectElement).value;
attrs.onSelect(Number(selectionValue));
},
},
optionComponents,
);
}
}
interface LogTextWidgetAttrs {
readonly trace: Trace;
readonly onChange: (value: string) => void;
}
class LogTextWidget implements m.ClassComponent<LogTextWidgetAttrs> {
view({attrs}: m.CVnode<LogTextWidgetAttrs>) {
return m(TextInput, {
placeholder: 'Search logs...',
onkeyup: (e: KeyboardEvent) => {
// We want to use the value of the input field after it has been
// updated with the latest key (onkeyup).
const htmlElement = e.target as HTMLInputElement;
attrs.onChange(htmlElement.value);
},
});
}
}
interface FilterByTextWidgetAttrs {
readonly hideNonMatching: boolean;
readonly disabled: boolean;
readonly onClick: () => void;
}
class FilterByTextWidget implements m.ClassComponent<FilterByTextWidgetAttrs> {
view({attrs}: m.Vnode<FilterByTextWidgetAttrs>) {
const icon = attrs.hideNonMatching ? Icons.Filter : Icons.FilterOff;
const tooltip = attrs.hideNonMatching
? 'Show all logs and highlight matches'
: 'Show only matching logs';
return m(Button, {
icon,
title: tooltip,
disabled: attrs.disabled,
onclick: attrs.onClick,
});
}
}
interface LogsFiltersAttrs {
readonly trace: Trace;
readonly cache: LogPanelCache;
readonly store: Store<LogFilteringCriteria>;
}
export class LogsFilters implements m.ClassComponent<LogsFiltersAttrs> {
view({attrs}: m.CVnode<LogsFiltersAttrs>) {
const hasMachineIds = attrs.cache.uniqueMachineIds.length > 1;
return [
m('span', 'Log Level'),
m(LogPriorityWidget, {
trace: attrs.trace,
options: LOG_PRIORITIES,
selectedIndex: attrs.store.state.minimumLevel,
onSelect: (minimumLevel) => {
attrs.store.edit((draft) => {
draft.minimumLevel = minimumLevel;
});
},
}),
m(TagInput, {
placeholder: 'Filter by tag...',
tags: attrs.store.state.tags,
onTagAdd: (tag) => {
attrs.store.edit((draft) => {
draft.tags.push(tag);
});
},
onTagRemove: (index) => {
attrs.store.edit((draft) => {
draft.tags.splice(index, 1);
});
},
}),
m(Button, {
icon: 'regular_expression',
title: 'Use regular expression',
active: !!attrs.store.state.isTagRegex,
onclick: () => {
attrs.store.edit((draft) => {
draft.isTagRegex = !draft.isTagRegex;
});
},
}),
m(LogTextWidget, {
trace: attrs.trace,
onChange: (text) => {
attrs.store.edit((draft) => {
draft.textEntry = text;
});
},
}),
m(FilterByTextWidget, {
hideNonMatching: attrs.store.state.hideNonMatching,
onClick: () => {
attrs.store.edit((draft) => {
draft.hideNonMatching = !draft.hideNonMatching;
});
},
disabled: attrs.store.state.textEntry === '',
}),
hasMachineIds && this.renderFilterPanel(attrs),
];
}
private renderFilterPanel(attrs: LogsFiltersAttrs) {
const machineExcludeList = attrs.store.state.machineExcludeList;
const options: MultiSelectOption[] = attrs.cache.uniqueMachineIds.map(
(uMachineId) => {
return {
id: String(uMachineId),
name: `Machine ${uMachineId}`,
checked: !machineExcludeList.some(
(excluded: number) => excluded === uMachineId,
),
};
},
);
return m(PopupMultiSelect, {
label: 'Filter by machine',
icon: Icons.Filter,
position: PopupPosition.Top,
options,
onChange: (diffs: MultiSelectDiff[]) => {
const newList = new Set<number>(machineExcludeList);
diffs.forEach(({checked, id}) => {
const machineId = Number(id);
if (checked) {
newList.delete(machineId);
} else {
newList.add(machineId);
}
});
attrs.store.edit((draft) => {
draft.machineExcludeList = Array.from(newList);
});
},
});
}
}
async function updateLogEntries(
engine: Engine,
span: TimeSpan,
pagination: Pagination,
): Promise<LogEntries> {
const rowsResult = await engine.query(`
select
ts,
pid,
tid,
prio,
ifnull(tag, '[NULL]') as tag,
ifnull(msg, '[NULL]') as msg,
is_msg_highlighted as isMsgHighlighted,
is_process_highlighted as isProcessHighlighted,
ifnull(process_name, '') as processName,
machine_id as machineId
from filtered_logs
where ts >= ${span.start} and ts <= ${span.end}
order by ts
limit ${pagination.offset}, ${pagination.count}
`);
const machineIds = [];
const timestamps: time[] = [];
const pids = [];
const tids = [];
const priorities = [];
const tags = [];
const messages = [];
const isHighlighted = [];
const processName = [];
const it = rowsResult.iter({
ts: LONG,
pid: LONG,
tid: LONG,
prio: NUM,
tag: STR,
msg: STR,
isMsgHighlighted: NUM_NULL,
isProcessHighlighted: NUM,
processName: STR,
machineId: NUM,
});
for (; it.valid(); it.next()) {
timestamps.push(Time.fromRaw(it.ts));
pids.push(it.pid);
tids.push(it.tid);
priorities.push(it.prio);
tags.push(it.tag);
messages.push(it.msg);
isHighlighted.push(
it.isMsgHighlighted === 1 || it.isProcessHighlighted === 1,
);
processName.push(it.processName);
machineIds.push(it.machineId);
}
const queryRes = await engine.query(`
select
count(*) as totalEvents
from filtered_logs
where ts >= ${span.start} and ts <= ${span.end}
`);
const {totalEvents} = queryRes.firstRow({totalEvents: NUM});
return {
offset: pagination.offset,
machineIds,
timestamps,
pids,
tids,
priorities,
tags,
messages,
isHighlighted,
processName,
totalEvents,
};
}
async function updateLogView(
engine: Engine,
filter: LogFilteringCriteria,
): Promise<AsyncDisposable> {
const globMatch = composeGlobMatch(filter.hideNonMatching, filter.textEntry);
let selectedRows = `select prio, ts, pid, tid, tag, msg,
process.name as process_name,
process.machine_id as machine_id, ${globMatch}
from android_logs
left join thread using(utid)
left join process using(upid)
where prio >= ${filter.minimumLevel}`;
if (filter.tags.length) {
if (filter.isTagRegex) {
const tagGlobClauses = filter.tags.map(
(pattern) => `tag glob ${escapeRegexQuery(pattern)}`,
);
selectedRows += ` and (${tagGlobClauses.join(' OR ')})`;
} else {
selectedRows += ` and tag in (${serializeTags(filter.tags)})`;
}
}
if (filter.machineExcludeList.length) {
selectedRows += ` and process.machine_id not in (${filter.machineExcludeList.join(',')})`;
}
// We extract only the rows which will be visible.
await engine.query(`create perfetto table filtered_logs as select *
from (${selectedRows})
where is_msg_chosen is 1 or is_process_chosen is 1`);
return {
async [Symbol.asyncDispose]() {
await engine.query('drop table filtered_logs');
},
};
}
function serializeTags(tags: string[]) {
return tags.map((tag) => escapeQuery(tag)).join();
}
function composeGlobMatch(isCollaped: boolean, textEntry: string) {
if (isCollaped) {
// If the entries are collapsed, we won't highlight any lines.
return `msg glob ${escapeSearchQuery(textEntry)} as is_msg_chosen,
(process.name is not null and process.name glob ${escapeSearchQuery(
textEntry,
)}) as is_process_chosen,
0 as is_msg_highlighted,
0 as is_process_highlighted`;
} else if (!textEntry) {
// If there is no text entry, we will show all lines, but won't highlight.
// any.
return `1 as is_msg_chosen,
1 as is_process_chosen,
0 as is_msg_highlighted,
0 as is_process_highlighted`;
} else {
return `1 as is_msg_chosen,
1 as is_process_chosen,
msg glob ${escapeSearchQuery(textEntry)} as is_msg_highlighted,
(process.name is not null and process.name glob ${escapeSearchQuery(
textEntry,
)}) as is_process_highlighted`;
}
}