Merge "Fix the LIMIT that didn't take effect on RepeatField()"
diff --git a/src/traced/probes/statsd_client/statsd_binder_data_source.cc b/src/traced/probes/statsd_client/statsd_binder_data_source.cc
index 9d3d7f7..df38bda 100644
--- a/src/traced/probes/statsd_client/statsd_binder_data_source.cc
+++ b/src/traced/probes/statsd_client/statsd_binder_data_source.cc
@@ -202,7 +202,7 @@
// static
const ProbesDataSource::Descriptor StatsdBinderDataSource::descriptor = {
- /*name*/ "android.statsd_binder",
+ /*name*/ "android.statsd",
/*flags*/ Descriptor::kFlagsNone,
/*fill_descriptor_func*/ nullptr,
};
diff --git a/test/configs/statsd.cfg b/test/configs/statsd.cfg
index 10d5b0d..a384b57 100644
--- a/test/configs/statsd.cfg
+++ b/test/configs/statsd.cfg
@@ -5,7 +5,7 @@
data_sources {
config {
- name: "android.statsd_binder"
+ name: "android.statsd"
target_buffer: 0
statsd_tracing_config {
push_atom_id: ATOM_FLASHLIGHT_STATE_CHANGED
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 15167eb..7b8f462 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -458,6 +458,15 @@
}
},
+ maybeSetPendingDeeplink(
+ state: StateDraft, args: {ts?: string, dur?: string, tid?: string}) {
+ state.pendingDeeplink = args;
+ },
+
+ clearPendingDeeplink(state: StateDraft, _: {}) {
+ state.pendingDeeplink = undefined;
+ },
+
// TODO(hjd): engine.ready should be a published thing. If it's part
// of the state it interacts badly with permalinks.
setEngineReady(
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index aa28c8e..52855b8 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -104,6 +104,7 @@
// 29. Add ftrace state. <-- Borked, state contains a non-serializable object.
// 30. Convert ftraceFilter.excludedNames from Set<string> to string[].
// 31. Convert all timestamps to bigints.
+// 32. Add pendingDeeplink.
export const STATE_VERSION = 31;
export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
@@ -515,6 +516,12 @@
excludedNames: string[];
}
+export interface PendingDeeplinkState {
+ ts?: string;
+ dur?: string;
+ tid?: string;
+}
+
export interface State {
version: number;
nextId: string;
@@ -609,6 +616,10 @@
// Omnibox info.
omniboxState: OmniboxState;
+
+ // Pending deeplink which will happen when we first finish opening a
+ // trace.
+ pendingDeeplink?: PendingDeeplinkState;
}
export const defaultTraceTime = {
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index f0e2d02..49301e3 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -39,7 +39,12 @@
STR_NULL,
} from '../common/query_result';
import {onSelectionChanged} from '../common/selection_observer';
-import {defaultTraceTime, EngineMode, ProfileType} from '../common/state';
+import {
+ defaultTraceTime,
+ EngineMode,
+ PendingDeeplinkState,
+ ProfileType,
+} from '../common/state';
import {Span} from '../common/time';
import {
TPTime,
@@ -523,6 +528,12 @@
await this.selectPerfSample();
}
+ const pendingDeeplink = globals.state.pendingDeeplink;
+ if (pendingDeeplink !== undefined) {
+ globals.dispatch(Actions.clearPendingDeeplink({}));
+ await this.selectPendingDeeplink(pendingDeeplink);
+ }
+
// If the trace was shared via a permalink, it might already have a
// selection. Emit onSelectionChanged to ensure that the components (like
// current selection details) react to it.
@@ -530,6 +541,8 @@
onSelectionChanged(globals.state.currentSelection, undefined);
}
+ globals.dispatch(Actions.maybeExpandOnlyTrackGroup({}));
+
// Trace Processor doesn't support the reliable range feature for JSON
// traces.
if (!isJsonTrace && ENABLE_CHROME_RELIABLE_RANGE_ANNOTATION_FLAG.get()) {
@@ -583,6 +596,52 @@
globals.dispatch(Actions.selectHeapProfile({id: 0, upid, ts, type}));
}
+ private async selectPendingDeeplink(link: PendingDeeplinkState) {
+ const conditions = [];
+ const {ts, dur} = link;
+
+ if (ts !== undefined) {
+ conditions.push(`ts = ${ts}`);
+ }
+ if (dur !== undefined) {
+ conditions.push(`dur = ${dur}`);
+ }
+
+ if (conditions.length === 0) {
+ return;
+ }
+
+ const query = `
+ select
+ id,
+ track_id as traceProcessorTrackId,
+ type
+ from slice
+ where ${conditions.join(' and ')}
+ ;`;
+
+ const result = await assertExists(this.engine).query(query);
+ if (result.numRows() > 0) {
+ const row = result.firstRow({
+ id: NUM,
+ traceProcessorTrackId: NUM,
+ type: STR,
+ });
+
+ const id = row.traceProcessorTrackId;
+ const trackId = globals.state.uiTrackIdByTraceTrackId[id];
+ if (trackId === undefined) {
+ return;
+ }
+ globals.makeSelection(Actions.selectChromeSlice({
+ id: row.id,
+ trackId,
+ table: '',
+ scroll: true,
+ }));
+ }
+ }
+
private async listTracks() {
this.updateStatus('Loading tracks');
const engine = assertExists<Engine>(this.engine);
diff --git a/ui/src/controller/validators.ts b/ui/src/controller/validators.ts
index c35c69d..cc072b7 100644
--- a/ui/src/controller/validators.ts
+++ b/ui/src/controller/validators.ts
@@ -85,6 +85,28 @@
}
}
+class OptionalValidator<T> implements Validator<T|undefined> {
+ private inner: Validator<T>;
+
+ constructor(inner: Validator<T>) {
+ this.inner = inner;
+ }
+
+ validate(input: unknown, context: ValidatorContext): T|undefined {
+ if (input === undefined) {
+ return undefined;
+ }
+ try {
+ return this.inner.validate(input, context);
+ } catch (e) {
+ if (e instanceof ValidationError) {
+ context.invalidKeys.push(renderPath(context.path));
+ return undefined;
+ }
+ throw e;
+ }
+ }
+}
class StringValidator extends PrimitiveValidator<string> {
predicate(input: unknown): input is string {
@@ -244,14 +266,25 @@
export const requiredStr = new StringValidator('', true);
+export const optStr = new OptionalValidator<string>(requiredStr);
+
export function num(defaultValue = 0): NumberValidator {
return new NumberValidator(defaultValue, false);
}
+export const requiredNum = new NumberValidator(0, true);
+
+export const optNum = new OptionalValidator<number>(requiredNum);
+
export function bool(defaultValue = false): BooleanValidator {
return new BooleanValidator(defaultValue, false);
}
+export const requiredBool = new BooleanValidator(false, true);
+
+export const optBool = new OptionalValidator<boolean>(requiredBool);
+
+
export function record<T extends Record<string, Validator<unknown>>>(
validators: T): RecordValidator<T> {
return new RecordValidator<T>(validators);
diff --git a/ui/src/controller/validators_unittest.ts b/ui/src/controller/validators_unittest.ts
index eec9aa5..fc0fe51 100644
--- a/ui/src/controller/validators_unittest.ts
+++ b/ui/src/controller/validators_unittest.ts
@@ -16,6 +16,7 @@
arrayOf,
num,
oneOf,
+ optStr,
record,
requiredStr,
runValidator,
@@ -107,3 +108,27 @@
expect(result.extraKeys).toContain('deeply.nested.array[1].extra3');
expect(result.invalidKeys).toContain('deeply.nested.array[0].x');
});
+
+
+describe('optStr', () => {
+ test('it validates undefined', () => {
+ const validation = runValidator(optStr, undefined);
+ expect(validation.result).toEqual(undefined);
+ expect(validation.invalidKeys).toEqual([]);
+ expect(validation.extraKeys).toEqual([]);
+ });
+
+ test('it validates string', () => {
+ const validation = runValidator(optStr, 'foo');
+ expect(validation.result).toEqual('foo');
+ expect(validation.invalidKeys).toEqual([]);
+ expect(validation.extraKeys).toEqual([]);
+ });
+
+ test('it reports numbers', () => {
+ const validation = runValidator(optStr, 42);
+ expect(validation.result).toEqual(undefined);
+ expect(validation.invalidKeys).toEqual(['']);
+ expect(validation.extraKeys).toEqual([]);
+ });
+});
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index cef61e8..053316f 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -142,12 +142,6 @@
}));
}
-function initGlobalsFromQueryString() {
- const queryString = window.location.search;
- globals.embeddedMode = queryString.includes('mode=embedded');
- globals.hideSidebar = queryString.includes('hideSidebar=true');
-}
-
function setupContentSecurityPolicy() {
// Note: self and sha-xxx must be quoted, urls data: and blob: must not.
const policy = {
@@ -252,9 +246,10 @@
maybeOpenTraceFromRoute(route);
};
- // This must be called before calling `globals.initialize` so that the
- // `embeddedMode` global is set.
- initGlobalsFromQueryString();
+ // These need to be set before globals.initialize.
+ const route = Router.parseUrl(window.location.href);
+ globals.embeddedMode = route.args.mode === 'embedded';
+ globals.hideSidebar = route.args.hideSidebar === true;
globals.initialize(dispatch, router);
globals.serviceWorkerController.install();
@@ -311,7 +306,6 @@
}
}
-
function onCssLoaded() {
initCssConstants();
// Clear all the contents of the initial page (e.g. the <pre> error message)
@@ -343,6 +337,14 @@
// accidentially clober the state of an open trace processor instance
// otherwise.
CheckHttpRpcConnection().then(() => {
+ const route = Router.parseUrl(window.location.href);
+
+ globals.dispatch(Actions.maybeSetPendingDeeplink({
+ ts: route.args.ts,
+ tid: route.args.tid,
+ dur: route.args.dur,
+ }));
+
if (!globals.embeddedMode) {
installFileDropHandler();
}
@@ -359,7 +361,7 @@
// Handles the initial ?local_cache_key=123 or ?s=permalink or ?url=...
// cases.
- maybeOpenTraceFromRoute(Router.parseUrl(window.location.href));
+ maybeOpenTraceFromRoute(route);
});
}
diff --git a/ui/src/frontend/router.ts b/ui/src/frontend/router.ts
index a693b2e..1057d80 100644
--- a/ui/src/frontend/router.ts
+++ b/ui/src/frontend/router.ts
@@ -13,20 +13,24 @@
// limitations under the License.
import m from 'mithril';
+
import {assertExists, assertTrue} from '../base/logging';
+import {
+ oneOf,
+ optBool,
+ optStr,
+ record,
+ runValidator,
+ ValidatedType,
+} from '../controller/validators';
+
import {PageAttrs} from './pages';
export const ROUTE_PREFIX = '#!';
const DEFAULT_ROUTE = '/';
-// A broken down representation of a route.
-// For instance: #!/record/gpu?local_cache_key=a0b1
-// becomes: {page: '/record', subpage: '/gpu', args: {local_cache_key: 'a0b1'}}
-export interface Route {
- page: string;
- subpage: string;
- args: RouteArgs;
-}
+const modes = ['embedded', undefined] as const;
+type Mode = typeof modes[number];
// The set of args that can be set on the route via #!/page?a=1&b2.
// Route args are orthogonal to pages (i.e. should NOT make sense only in a
@@ -44,18 +48,46 @@
// This is client-only. All the routing logic in the Perfetto UI uses only
// this.
-// This must be a type literial to avoid having to duplicate the
-// index type logic of Params.
-export type RouteArgs = {
+const routeArgs = record({
// The local_cache_key is special and is persisted across navigations.
- local_cache_key?: string;
+ local_cache_key: optStr,
// These are transient and are really set only on startup.
- openFromAndroidBugTool?: string;
- s?: string; // For permalinks.
- p?: string; // DEPRECATED: for #!/record?p=cpu subpages (b/191255021).
- url?: string; // For fetching traces from Cloud Storage.
-};
+
+ // Are we loading a trace via ABT.
+ openFromAndroidBugTool: optBool,
+
+ // For permalink hash.
+ s: optStr,
+
+ // DEPRECATED: for #!/record?p=cpu subpages (b/191255021).
+ p: optStr,
+
+ // For fetching traces from Cloud Storage.
+ url: optStr,
+
+ // For the 'mode' of the UI. For example when the mode is 'embedded'
+ // some features are disabled.
+ mode: oneOf<Mode>(modes, undefined),
+
+ // Should we hide the sidebar?
+ hideSidebar: optBool,
+
+ // Deep link support
+ ts: optStr,
+ dur: optStr,
+ tid: optStr,
+});
+type RouteArgs = ValidatedType<typeof routeArgs>;
+
+// A broken down representation of a route.
+// For instance: #!/record/gpu?local_cache_key=a0b1
+// becomes: {page: '/record', subpage: '/gpu', args: {local_cache_key: 'a0b1'}}
+export interface Route {
+ page: string;
+ subpage: string;
+ args: RouteArgs;
+}
export interface RoutesMap {
[key: string]: m.Component<PageAttrs>;
@@ -168,16 +200,50 @@
const argsStart = hash.indexOf('?');
const argsStr = argsStart < 0 ? '' : hash.substring(argsStart + 1);
- const args = argsStr ? m.parseQueryString(hash.substring(argsStart)) : {};
+ const rawArgs =
+ argsStr ? m.parseQueryString(hash.substring(argsStart)) : {};
+
+ const args = runValidator(routeArgs, rawArgs).result;
+
+ // Javascript sadly distinguishes between foo[bar] === undefined
+ // and foo[bar] is not set at all. Here we need the second case to
+ // avoid making the URL ugly.
+ for (const key of Object.keys(args)) {
+ if ((args as any)[key] === undefined) {
+ delete (args as any)[key];
+ }
+ }
return {page, subpage, args};
}
+ private static parseSearchParams(url: string): RouteArgs {
+ const query = (new URL(url)).search;
+ const rawArgs = m.parseQueryString(query);
+ const args = runValidator(routeArgs, rawArgs).result;
+
+ // Javascript sadly distinguishes between foo[bar] === undefined
+ // and foo[bar] is not set at all. Here we need the second case to
+ // avoid making the URL ugly.
+ for (const key of Object.keys(args)) {
+ if ((args as any)[key] === undefined) {
+ delete (args as any)[key];
+ }
+ }
+
+ return args;
+ }
+
// Like parseFragment() but takes a full URL.
static parseUrl(url: string): Route {
+ const searchArgs = Router.parseSearchParams(url);
+
const hashPos = url.indexOf('#');
const fragment = hashPos < 0 ? '' : url.substring(hashPos);
- return Router.parseFragment(fragment);
+ const route = Router.parseFragment(fragment);
+ route.args = Object.assign({}, searchArgs, route.args);
+
+ return route;
}
// Throws if EVENT_LIMIT onhashchange events occur within WINDOW_MS.
diff --git a/ui/src/frontend/router_unittest.ts b/ui/src/frontend/router_unittest.ts
index 612347c..19dac36 100644
--- a/ui/src/frontend/router_unittest.ts
+++ b/ui/src/frontend/router_unittest.ts
@@ -18,89 +18,138 @@
view() {},
};
-beforeEach(() => {
- window.location.hash = '';
-});
-
-test('Default route must be defined', () => {
- expect(() => new Router({'/a': mockComponent})).toThrow();
-});
-
-test('Resolves empty route to default component', () => {
- const router = new Router({'/': mockComponent});
- window.location.hash = '';
- expect(router.resolve().tag).toBe(mockComponent);
-});
-
-test('Resolves subpage route to component of main page', () => {
- const nonDefaultComponent = {view() {}};
- const router = new Router({
- '/': mockComponent,
- '/a': nonDefaultComponent,
+describe('Router#resolve', () => {
+ beforeEach(() => {
+ window.location.hash = '';
});
- window.location.hash = '#!/a/subpage';
- expect(router.resolve().tag).toBe(nonDefaultComponent);
- expect(router.resolve().attrs.subpage).toBe('/subpage');
-});
-test('Pass empty subpage if not found in URL', () => {
- const nonDefaultComponent = {view() {}};
- const router = new Router({
- '/': mockComponent,
- '/a': nonDefaultComponent,
+ test('Default route must be defined', () => {
+ expect(() => new Router({'/a': mockComponent})).toThrow();
});
- window.location.hash = '#!/a';
- expect(router.resolve().tag).toBe(nonDefaultComponent);
- expect(router.resolve().attrs.subpage).toBe('');
+
+ test('Resolves empty route to default component', () => {
+ const router = new Router({'/': mockComponent});
+ window.location.hash = '';
+ expect(router.resolve().tag).toBe(mockComponent);
+ });
+
+ test('Resolves subpage route to component of main page', () => {
+ const nonDefaultComponent = {view() {}};
+ const router = new Router({
+ '/': mockComponent,
+ '/a': nonDefaultComponent,
+ });
+ window.location.hash = '#!/a/subpage';
+ expect(router.resolve().tag).toBe(nonDefaultComponent);
+ expect(router.resolve().attrs.subpage).toBe('/subpage');
+ });
+
+ test('Pass empty subpage if not found in URL', () => {
+ const nonDefaultComponent = {view() {}};
+ const router = new Router({
+ '/': mockComponent,
+ '/a': nonDefaultComponent,
+ });
+ window.location.hash = '#!/a';
+ expect(router.resolve().tag).toBe(nonDefaultComponent);
+ expect(router.resolve().attrs.subpage).toBe('');
+ });
});
-test('Args parsing', () => {
- const url = 'http://localhost/#!/foo?p=123&s=42&url=a?b?c';
- const args = Router.parseUrl(url).args;
- expect(args.p).toBe('123');
- expect(args.s).toBe('42');
- expect(args.url).toBe('a?b?c');
+describe('Router.parseUrl', () => {
+ // Can parse arguments from the search string.
+ test('Search parsing', () => {
+ const url = 'http://localhost?p=123&s=42&url=a?b?c';
+ const args = Router.parseUrl(url).args;
+ expect(args.p).toBe('123');
+ expect(args.s).toBe('42');
+ expect(args.url).toBe('a?b?c');
+ });
+
+ // Or from the fragment string.
+ test('Fragment parsing', () => {
+ const url = 'http://localhost/#!/foo?p=123&s=42&url=a?b?c';
+ const args = Router.parseUrl(url).args;
+ expect(args.p).toBe('123');
+ expect(args.s).toBe('42');
+ expect(args.url).toBe('a?b?c');
+ });
+
+ // Or both in which case fragment overrides the search.
+ test('Fragment parsing', () => {
+ const url =
+ 'http://localhost/?p=1&s=2&hideSidebar=true#!/foo?s=3&url=4&hideSidebar=false';
+ const args = Router.parseUrl(url).args;
+ expect(args.p).toBe('1');
+ expect(args.s).toBe('3');
+ expect(args.url).toBe('4');
+ expect(args.hideSidebar).toBe(false);
+ });
});
-test('empty route broken into empty components', () => {
- const {page, subpage, args} = Router.parseFragment('');
- expect(page).toBe('');
- expect(subpage).toBe('');
- expect(args).toEqual({});
-});
+describe('Router.parseFragment', () => {
+ test('empty route broken into empty components', () => {
+ const {page, subpage, args} = Router.parseFragment('');
+ expect(page).toBe('');
+ expect(subpage).toBe('');
+ expect(args.mode).toBe(undefined);
+ });
-test('invalid route broken into empty components', () => {
- const {page, subpage, args} = Router.parseFragment('/bla');
- expect(page).toBe('');
- expect(subpage).toBe('');
- expect(args).toEqual({});
-});
+ test('by default args are undefined', () => {
+ // This prevents the url from becoming messy.
+ const {args} = Router.parseFragment('');
+ expect(args).toEqual({});
+ });
-test('simple route has page defined', () => {
- const {page, subpage, args} = Router.parseFragment('#!/record');
- expect(page).toBe('/record');
- expect(subpage).toBe('');
- expect(args).toEqual({});
-});
+ test('invalid route broken into empty components', () => {
+ const {page, subpage} = Router.parseFragment('/bla');
+ expect(page).toBe('');
+ expect(subpage).toBe('');
+ });
-test('simple route has both components defined', () => {
- const {page, subpage, args} = Router.parseFragment('#!/record/memory');
- expect(page).toBe('/record');
- expect(subpage).toBe('/memory');
- expect(args).toEqual({});
-});
+ test('simple route has page defined', () => {
+ const {page, subpage} = Router.parseFragment('#!/record');
+ expect(page).toBe('/record');
+ expect(subpage).toBe('');
+ });
-test('route broken at first slash', () => {
- const {page, subpage, args} = Router.parseFragment('#!/record/memory/stuff');
- expect(page).toBe('/record');
- expect(subpage).toBe('/memory/stuff');
- expect(args).toEqual({});
-});
+ test('simple route has both components defined', () => {
+ const {page, subpage} = Router.parseFragment('#!/record/memory');
+ expect(page).toBe('/record');
+ expect(subpage).toBe('/memory');
+ });
-test('parameters separated from route', () => {
- const {page, subpage, args} =
- Router.parseFragment('#!/record/memory?url=http://localhost:1234/aaaa');
- expect(page).toBe('/record');
- expect(subpage).toBe('/memory');
- expect(args).toEqual({url: 'http://localhost:1234/aaaa'});
+ test('route broken at first slash', () => {
+ const {page, subpage} = Router.parseFragment('#!/record/memory/stuff');
+ expect(page).toBe('/record');
+ expect(subpage).toBe('/memory/stuff');
+ });
+
+ test('parameters separated from route', () => {
+ const {page, subpage, args} =
+ Router.parseFragment('#!/record/memory?url=http://localhost:1234/aaaa');
+ expect(page).toBe('/record');
+ expect(subpage).toBe('/memory');
+ expect(args.url).toEqual('http://localhost:1234/aaaa');
+ });
+
+ test('openFromAndroidBugTool can be false', () => {
+ const {args} = Router.parseFragment('#!/?openFromAndroidBugTool=false');
+ expect(args.openFromAndroidBugTool).toEqual(false);
+ });
+
+ test('openFromAndroidBugTool can be true', () => {
+ const {args} = Router.parseFragment('#!/?openFromAndroidBugTool=true');
+ expect(args.openFromAndroidBugTool).toEqual(true);
+ });
+
+ test('bad modes are coerced to default', () => {
+ const {args} = Router.parseFragment('#!/?mode=1234');
+ expect(args.mode).toEqual(undefined);
+ });
+
+ test('bad hideSidebar is coerced to default', () => {
+ const {args} = Router.parseFragment('#!/?hideSidebar=helloworld!');
+ expect(args.hideSidebar).toEqual(undefined);
+ });
});
diff --git a/ui/src/frontend/tickmark_panel.ts b/ui/src/frontend/tickmark_panel.ts
index ccf4d4c..6076188 100644
--- a/ui/src/frontend/tickmark_panel.ts
+++ b/ui/src/frontend/tickmark_panel.ts
@@ -75,7 +75,7 @@
size.height);
}
const index = globals.state.searchIndex;
- if (index !== -1) {
+ if (index !== -1 && index <= globals.currentSearchResults.tsStarts.length) {
const start = globals.currentSearchResults.tsStarts[index];
const triangleStart =
Math.max(visibleTimeScale.tpTimeToPx(start), 0) + TRACK_SHELL_WIDTH;
diff --git a/ui/src/frontend/trace_url_handler.ts b/ui/src/frontend/trace_url_handler.ts
index 8b398fb..5e329f5 100644
--- a/ui/src/frontend/trace_url_handler.ts
+++ b/ui/src/frontend/trace_url_handler.ts
@@ -23,7 +23,6 @@
import {Route, Router} from './router';
import {taskTracker} from './task_tracker';
-
export function maybeOpenTraceFromRoute(route: Route) {
if (route.args.s) {
// /?s=xxxx for permalinks.