| // Copyright (C) 2019 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use size 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 {sqliteString} from '../base/string_utils'; |
| import {Actions} from '../common/actions'; |
| import {ArgValue} from '../common/arg_types'; |
| import {EngineProxy} from '../common/engine'; |
| import {runQuery} from '../common/queries'; |
| import { |
| formatDuration, |
| TPDuration, |
| tpDurationToSeconds, |
| TPTime, |
| } from '../common/time'; |
| import {Argument, convertArgsToTree, Key} from '../controller/args_parser'; |
| |
| import {Anchor} from './anchor'; |
| import {FlowPoint, globals, SliceDetails} from './globals'; |
| import {runQueryInNewTab} from './query_result_tab'; |
| import {verticalScrollToTrack} from './scroll_helper'; |
| import {Icons} from './semantic_icons'; |
| import {asTPTimestamp} from './sql_types'; |
| import {Button} from './widgets/button'; |
| import {DetailsShell} from './widgets/details_shell'; |
| import {Duration} from './widgets/duration'; |
| import {Column, GridLayout} from './widgets/grid_layout'; |
| import {MenuItem, PopupMenu2} from './widgets/menu'; |
| import {Section} from './widgets/section'; |
| import {SqlRef} from './widgets/sql_ref'; |
| import {Timestamp} from './widgets/timestamp'; |
| import {Tree, TreeNode} from './widgets/tree'; |
| import {exists} from './widgets/utils'; |
| |
| interface ContextMenuItem { |
| name: string; |
| shouldDisplay(slice: SliceDetails): boolean; |
| getAction(slice: SliceDetails): void; |
| } |
| |
| const ITEMS: ContextMenuItem[] = [ |
| { |
| name: 'Average duration', |
| shouldDisplay: (slice: SliceDetails) => slice.name !== undefined, |
| getAction: (slice: SliceDetails) => runQueryInNewTab( |
| `SELECT AVG(dur) / 1e9 FROM slice WHERE name = '${slice.name!}'`, |
| `${slice.name} average dur`, |
| ), |
| }, |
| { |
| name: 'Binder by TXN', |
| shouldDisplay: () => true, |
| getAction: () => runQueryInNewTab( |
| `SELECT IMPORT('android.binder'); |
| |
| SELECT * |
| FROM android_sync_binder_metrics_by_txn |
| ORDER BY client_dur DESC`, |
| 'Binder by TXN', |
| ), |
| }, |
| { |
| name: 'Binder call names', |
| shouldDisplay: () => true, |
| getAction: (slice: SliceDetails) => { |
| const engine = getEngine(); |
| if (engine === undefined) return; |
| runQuery(`SELECT IMPORT('android.binder');`, engine) |
| .then( |
| () => runQueryInNewTab( |
| ` |
| SELECT s.ts, s.dur, tx.aidl_name AS name, s.id |
| FROM android_sync_binder_metrics_by_txn tx |
| JOIN slice s ON tx.binder_txn_id = s.id |
| JOIN thread_track ON s.track_id = thread_track.id |
| JOIN thread USING (utid) |
| JOIN process USING (upid) |
| WHERE aidl_name IS NOT NULL |
| AND pid = ${slice.pid} |
| AND tid = ${slice.tid}`, |
| `Binder names (${slice.processName}:${slice.tid})`, |
| )); |
| }, |
| }, |
| { |
| name: 'Lock graph', |
| shouldDisplay: (slice: SliceDetails) => slice.id !== undefined, |
| getAction: (slice: SliceDetails) => runQueryInNewTab( |
| `SELECT IMPORT('android.monitor_contention'); |
| DROP TABLE IF EXISTS FAST; |
| CREATE TABLE FAST |
| AS |
| WITH slice_process AS ( |
| SELECT process.name, process.upid FROM slice |
| JOIN thread_track ON thread_track.id = slice.track_id |
| JOIN thread USING(utid) |
| JOIN process USING(upid) |
| WHERE slice.id = ${slice.id} |
| ) |
| SELECT *, |
| IIF(blocked_thread_name LIKE 'binder:%', 'binder', blocked_thread_name) |
| AS blocked_thread_name_norm, |
| IIF(blocking_thread_name LIKE 'binder:%', 'binder', blocking_thread_name) |
| AS blocking_thread_name_norm |
| FROM android_monitor_contention_chain, slice_process |
| WHERE android_monitor_contention_chain.upid = slice_process.upid; |
| |
| WITH |
| R AS ( |
| SELECT |
| id, |
| dur, |
| CAT_STACKS(blocked_thread_name_norm || ':' || short_blocked_method, |
| blocking_thread_name_norm || ':' || short_blocking_method) AS stack |
| FROM FAST |
| WHERE parent_id IS NULL |
| UNION ALL |
| SELECT |
| c.id, |
| c.dur AS dur, |
| CAT_STACKS(stack, blocking_thread_name_norm || ':' || short_blocking_method) AS stack |
| FROM FAST c, R AS p |
| WHERE p.id = c.parent_id |
| ) |
| SELECT TITLE.process_name, EXPERIMENTAL_PROFILE(stack, 'duration', 'ns', dur) AS pprof |
| FROM R, (SELECT process_name FROM FAST LIMIT 1) TITLE;`, |
| 'Lock graph', |
| ), |
| }, |
| ]; |
| |
| function getSliceContextMenuItems(slice: SliceDetails) { |
| return ITEMS.filter((item) => item.shouldDisplay(slice)); |
| } |
| |
| function getEngine(): EngineProxy|undefined { |
| const engineId = globals.getCurrentEngine()?.id; |
| if (engineId === undefined) { |
| return undefined; |
| } |
| const engine = globals.engines.get(engineId)?.getProxy('SlicePanel'); |
| return engine; |
| } |
| |
| function getArgValueRaw(value: ArgValue): string { |
| if (typeof value === 'object') { |
| return value.rawValue; |
| } else { |
| return value; |
| } |
| } |
| |
| // Renders a key with a button to get dropdown things |
| function renderArgKey( |
| key: string, fullKey?: string, value?: ArgValue): m.Children { |
| if (value === undefined || fullKey === undefined) { |
| return key; |
| } else { |
| return m( |
| PopupMenu2, |
| {trigger: m(Anchor, {icon: Icons.ContextMenu}, key)}, |
| fullKey && m(MenuItem, { |
| label: 'Copy full key', |
| icon: 'content_copy', |
| onclick: () => { |
| navigator.clipboard.writeText(fullKey); |
| }, |
| }), |
| value && fullKey && m(MenuItem, { |
| label: 'Find slices with same arg value', |
| icon: 'search', |
| onclick: () => { |
| runQueryInNewTab( |
| ` |
| select slice.* |
| from slice |
| join args using (arg_set_id) |
| where key=${sqliteString(fullKey)} and display_value=${ |
| sqliteString(getArgValueRaw(value))} |
| `, |
| `Arg: ${sqliteString(fullKey)}=${ |
| sqliteString(getArgValueRaw(value))}`); |
| }, |
| }), |
| value && fullKey && m(MenuItem, { |
| label: 'Visualise argument values', |
| icon: 'query_stats', |
| onclick: () => { |
| globals.dispatch(Actions.addVisualisedArg({argName: fullKey})); |
| }, |
| }), |
| ); |
| } |
| } |
| |
| // Try to render arg value as a special value, otherwise just render the text. |
| function renderArgValue(value: ArgValue): m.Children { |
| if (typeof value === 'object' && 'kind' in value) { |
| const {kind} = value; |
| if (kind === 'SLICE') { |
| // Value looks like a slice link. |
| const {sliceId, trackId} = value; |
| return renderSliceLink(sliceId, trackId, `slice[${sliceId}]`); |
| } else { |
| const x: never = kind; |
| throw new Error(`No support for args of kind '${x}'`); |
| } |
| } else if (typeof value === 'string') { |
| if (value.startsWith('http://') || value.startsWith('https://')) { |
| // Value looks like a web link. |
| return m( |
| Anchor, {href: value, target: '_blank', icon: 'open_in_new'}, value); |
| } else { |
| // Value is nothing special. |
| return value; |
| } |
| } else { |
| const x: never = value; |
| throw new Error(`Unable to process '${x}' as an arg value`); |
| } |
| } |
| |
| function renderSliceLink(id: number, trackId: string, name: string) { |
| return m( |
| Anchor, |
| { |
| icon: 'call_made', |
| onclick: () => { |
| globals.makeSelection( |
| Actions.selectChromeSlice({id, trackId, table: 'slice'})); |
| // Ideally we want to have a callback to |
| // findCurrentSelection after this selection has been |
| // made. Here we do not have the info for horizontally |
| // scrolling to ts. |
| verticalScrollToTrack(trackId, true); |
| }, |
| }, |
| name); |
| } |
| |
| function renderSummary(children: Argument<ArgValue>[]): m.Children { |
| const summary = children.slice(0, 2).map(({key}) => key).join(', '); |
| const remaining = children.length - 2; |
| if (remaining > 0) { |
| return `{${summary}, ... (${remaining} more items)}`; |
| } else { |
| return `{${summary}}`; |
| } |
| } |
| |
| // Format any number of keys into a composite key with standardized formatting. |
| function stringifyKey(...key: Key[]): string { |
| return key |
| .map((element, index) => { |
| if (typeof element === 'number') { |
| return `[${element}]`; |
| } else { |
| return (index === 0 ? '' : '.') + element; |
| } |
| }) |
| .join(''); |
| } |
| |
| function renderArgTreeNodes(args: Argument<ArgValue>[]): m.Children { |
| return args.map((arg) => { |
| const {key, path, value, children} = arg; |
| if (children && children.length === 1) { |
| // If we only have one child, collapse into self and combine keys |
| const child = children[0]; |
| const compositeArg = { |
| ...child, |
| key: stringifyKey(key, child.key), |
| }; |
| return renderArgTreeNodes([compositeArg]); |
| } else { |
| return m( |
| TreeNode, |
| { |
| left: renderArgKey(stringifyKey(key), path, value), |
| right: exists(value) && renderArgValue(value), |
| summary: children && renderSummary(children), |
| }, |
| children && renderArgTreeNodes(children), |
| ); |
| } |
| }); |
| } |
| |
| interface Sliceish extends SliceDetails { |
| ts: TPTime; |
| dur: TPDuration; |
| name: string; |
| } |
| |
| function isSliceish(slice: SliceDetails): slice is Sliceish { |
| return exists(slice.ts) && exists(slice.dur) && exists(slice.name); |
| } |
| |
| function getDisplayName(name: string|undefined, id: number|undefined): string| |
| undefined { |
| if (name === undefined) { |
| return id === undefined ? undefined : `${id}`; |
| } else { |
| return id === undefined ? name : `${name} ${id}`; |
| } |
| } |
| |
| function computeDuration(ts: TPTime, dur: TPDuration): m.Children { |
| if (dur === -1n) { |
| const minDuration = globals.state.traceTime.end - ts; |
| return `${formatDuration(minDuration)} (Did not end)`; |
| } else { |
| return m(Duration, {dur}); |
| } |
| } |
| |
| export class ChromeSliceDetailsPanel implements m.ClassComponent { |
| view() { |
| const slice = globals.sliceDetails; |
| if (isSliceish(slice)) { |
| return m( |
| DetailsShell, |
| { |
| title: 'Slice', |
| description: slice.name, |
| buttons: this.renderContextButton(slice), |
| }, |
| m( |
| GridLayout, |
| this.renderDetails(slice), |
| this.renderRhs(slice), |
| ), |
| ); |
| } else { |
| return m(DetailsShell, {title: 'Slice', description: 'Loading...'}); |
| } |
| } |
| |
| private renderRhs(slice: Sliceish): m.Children { |
| const precFlows = this.renderPrecedingFlows(slice); |
| const followingFlows = this.renderFollowingFlows(slice); |
| const args = this.renderArguments(slice); |
| if (precFlows ?? followingFlows ?? args) { |
| return m( |
| Column, |
| precFlows, |
| followingFlows, |
| args, |
| ); |
| } else { |
| return undefined; |
| } |
| } |
| |
| private renderDetails(slice: Sliceish) { |
| return m( |
| Section, |
| {title: 'Details'}, |
| m(Tree, |
| m(TreeNode, {left: 'Name', right: slice.name}), |
| m(TreeNode, { |
| left: 'Category', |
| right: !slice.category || slice.category === '[NULL]' ? |
| 'N/A' : |
| slice.category, |
| }), |
| m(TreeNode, { |
| left: 'Start time', |
| right: m(Timestamp, {ts: asTPTimestamp(slice.ts)}), |
| }), |
| exists(slice.absTime) && |
| m(TreeNode, {left: 'Absolute Time', right: slice.absTime}), |
| m(TreeNode, { |
| left: 'Duration', |
| right: computeDuration(slice.ts, slice.dur), |
| }), |
| this.renderThreadDuration(slice), |
| Array.from(this.getProcessThreadDetails(slice)) |
| .map( |
| ([key, value]) => |
| exists(value) && m(TreeNode, {left: key, right: value})), |
| m(TreeNode, { |
| left: 'SQL ID', |
| right: m(SqlRef, {table: 'slice', id: slice.id}), |
| }), |
| slice.description && |
| Array.from(slice.description) |
| .map( |
| ([key, value]) => m(TreeNode, {left: key, right: value}), |
| ))); |
| } |
| |
| private getProcessThreadDetails(sliceInfo: SliceDetails) { |
| return new Map<string, string|undefined>([ |
| ['Thread', getDisplayName(sliceInfo.threadName, sliceInfo.tid)], |
| ['Process', getDisplayName(sliceInfo.processName, sliceInfo.pid)], |
| ['User ID', sliceInfo.uid ? String(sliceInfo.uid) : undefined], |
| ['Package name', sliceInfo.packageName], |
| [ |
| 'Version code', |
| sliceInfo.versionCode ? String(sliceInfo.versionCode) : undefined, |
| ], |
| ]); |
| } |
| |
| private renderThreadDuration(sliceInfo: Sliceish) { |
| if (exists(sliceInfo.threadTs) && exists(sliceInfo.threadDur)) { |
| // If we have valid thread duration, also display a percentage of |
| // |threadDur| compared to |dur|. |
| const ratio = tpDurationToSeconds(sliceInfo.threadDur) / |
| tpDurationToSeconds(sliceInfo.dur); |
| const threadDurFractionSuffix = |
| sliceInfo.threadDur === -1n ? '' : ` (${(ratio * 100).toFixed(2)}%)`; |
| return m(TreeNode, { |
| left: 'Thread duration', |
| right: [ |
| computeDuration(sliceInfo.threadTs, sliceInfo.threadDur), |
| threadDurFractionSuffix, |
| ], |
| }); |
| } else { |
| return undefined; |
| } |
| } |
| |
| private renderPrecedingFlows(slice: Sliceish): m.Children { |
| const flows = globals.connectedFlows; |
| const inFlows = flows.filter(({end}) => end.sliceId === slice.id); |
| |
| if (inFlows.length > 0) { |
| const isRunTask = slice.name === 'ThreadControllerImpl::RunTask' || |
| slice.name === 'ThreadPool_RunTask'; |
| |
| return m( |
| Section, |
| {title: 'Preceding Flows'}, |
| m( |
| Tree, |
| inFlows.map( |
| ({begin, dur}) => this.renderFlow(begin, dur, !isRunTask)), |
| )); |
| } else { |
| return null; |
| } |
| } |
| |
| private renderFollowingFlows(slice: Sliceish): m.Children { |
| const flows = globals.connectedFlows; |
| const outFlows = flows.filter(({begin}) => begin.sliceId === slice.id); |
| |
| if (outFlows.length > 0) { |
| const isPostTask = slice.name === 'ThreadPool_PostTask' || |
| slice.name === 'SequenceManager PostTask'; |
| |
| return m( |
| Section, |
| {title: 'Following Flows'}, |
| m( |
| Tree, |
| outFlows.map( |
| ({end, dur}) => this.renderFlow(end, dur, !isPostTask)), |
| )); |
| } else { |
| return null; |
| } |
| } |
| |
| private renderFlow( |
| flow: FlowPoint, dur: TPDuration, |
| includeProcessName: boolean): m.Children { |
| const sliceId = flow.sliceId; |
| const trackId = globals.state.uiTrackIdByTraceTrackId[flow.trackId]; |
| const description = flow.sliceChromeCustomName === undefined ? |
| flow.sliceName : |
| flow.sliceChromeCustomName; |
| const sliceLink = renderSliceLink(sliceId, trackId, description); |
| const threadName = includeProcessName ? |
| `${flow.threadName} (${flow.processName})` : |
| flow.threadName; |
| return m( |
| TreeNode, |
| {left: 'Flow'}, |
| m(TreeNode, {left: 'Slice', right: sliceLink}), |
| m(TreeNode, {left: 'Delay', right: m(Duration, {dur})}), |
| m(TreeNode, {left: 'Thread', right: threadName}), |
| ); |
| } |
| |
| private renderArguments(slice: Sliceish): m.Children { |
| if (slice.args && slice.args.size > 0) { |
| const tree = convertArgsToTree(slice.args); |
| return m( |
| Section, {title: 'Arguments'}, m(Tree, renderArgTreeNodes(tree))); |
| } else { |
| return undefined; |
| } |
| } |
| |
| private renderContextButton(sliceInfo: SliceDetails): m.Children { |
| const contextMenuItems = getSliceContextMenuItems(sliceInfo); |
| if (contextMenuItems.length > 0) { |
| const trigger = m(Button, { |
| minimal: true, |
| compact: true, |
| label: 'Contextual Options', |
| rightIcon: Icons.ContextMenu, |
| }); |
| return m( |
| PopupMenu2, |
| {trigger}, |
| contextMenuItems.map( |
| ({name, getAction}) => |
| m(MenuItem, {label: name, onclick: getAction})), |
| ); |
| } else { |
| return undefined; |
| } |
| } |
| } |