blob: 8230cbadd9431ea955d0873a8a4745c3a66de2d3 [file]
// Copyright (C) 2026 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 type {Row as DataGridRow} from '../../trace_processor/query_result';
import type {Filter} from '../../components/widgets/datagrid/model';
import type {SettingFilter} from '../settings/settings_types';
import {coerceFiltersForWire} from './filter_encoding';
import type {RawQueryExecution} from './query_history_storage';
// Tabular wire shape. Values are always strings; JSON null denotes SQL NULL.
interface QueryResponsePayload {
queryUuid?: string;
columnNames?: string[];
rows?: Array<{values: Array<string | null>}>;
// Filtered count for scrollbar sizing.
totalFilteredRows?: number;
// Every column the client could project (result + metadata columns), so the
// picker can offer ones the current projection omits. `:fetch_results`-only.
availableColumnNames?: string[];
}
export interface QueryResultPage {
readonly rows: ReadonlyArray<DataGridRow>;
readonly columns: ReadonlyArray<string>;
readonly queryUuid?: string;
// Post-filter count from `:fetch_results`; undefined elsewhere.
readonly totalFilteredRows?: number;
// Every projectable column (result + metadata columns); `:fetch_results`-only.
readonly availableColumnNames?: ReadonlyArray<string>;
}
// One column the `/trace_metadata` endpoint can return for the current trace
// source. `defaultVisible` flags the columns the grid shows on first render;
// `type` is informational (the wire is always-strings).
export interface TraceColumnDescriptor {
readonly name: string;
readonly type: string;
readonly defaultVisible: boolean;
readonly description?: string;
}
// `/trace_metadata_schema` response: the column catalog for the trace-list
// grid + the column-picker widget.
export interface TracesSchemaResponse {
readonly columns: ReadonlyArray<TraceColumnDescriptor>;
}
// The submit-time trace-selection snapshot shipped as top-level fields on
// /execute_*. Each is omitted from the wire when empty / default, so a query
// run with no trace selection sends just the base limit/perfetto_sql/settings.
export interface ExecuteOptions {
// Structured filter picking which traces the query runs over. Shipped as a
// native JSON array via coerceFiltersForWire.
readonly traceFilters?: ReadonlyArray<Filter>;
// Trace-metadata columns to attach to each result row.
readonly traceMetadataColumns?: ReadonlyArray<string>;
// AIP-132 ordering for the trace processing order.
readonly traceOrderBy?: string;
// Non-negative cap on traces fanned out; 0 / undefined means no cap.
readonly traceLimit?: number;
}
// Request aborted via AbortSignal — treat as cancellation, not an error.
export class QueryCancelledError extends Error {
constructor() {
super('Query was cancelled.');
this.name = 'QueryCancelledError';
}
}
// Backend returned 404 for a UUID; distinct from generic HTTP errors so
// callers can drop the dead reference instead of polling forever.
export class QueryNotFoundError extends Error {
constructor(uuid: string) {
super(`Query ${uuid} not found on the backend.`);
this.name = 'QueryNotFoundError';
}
}
// Single funnel for the BigTrace HTTP API.
export class BigtraceQueryClient {
constructor(private readonly endpoint: string) {}
// ----- Query execution -----
async executeSync(
query: string,
limit: number,
settings: ReadonlyArray<SettingFilter>,
signal?: AbortSignal,
options?: ExecuteOptions,
): Promise<QueryResultPage> {
return this.executeAt(
'/execute_bigtrace_query',
query,
limit,
settings,
signal,
options,
);
}
async executeAsync(
query: string,
limit: number,
settings: ReadonlyArray<SettingFilter>,
signal?: AbortSignal,
options?: ExecuteOptions,
): Promise<QueryResultPage> {
return this.executeAt(
'/execute_bigtrace_query_async',
query,
limit,
settings,
signal,
options,
);
}
async getStatus(
uuid: string,
signal?: AbortSignal,
): Promise<RawQueryExecution> {
return this.requestJson<RawQueryExecution>(
`/query_executions/${uuid}:status`,
{signal},
);
}
async getQueryExecution(
uuid: string,
signal?: AbortSignal,
): Promise<RawQueryExecution> {
return this.requestJson<RawQueryExecution>(`/query_executions/${uuid}`, {
signal,
});
}
// Page a query's results. POST body: `limit`/`offset` plus optional
// `order_by` (AIP-132), `filters` (native Filter[]), and `columns` field-mask
// over (result cols + metadata columns). The response echoes that set as
// `availableColumnNames`. Mid-flight calls return whatever rows are ready.
async fetchResults(
uuid: string,
limit: number,
offset: number,
signal?: AbortSignal,
orderBy?: string,
filter?: ReadonlyArray<Filter>,
columns?: ReadonlyArray<string>,
): Promise<QueryResultPage> {
const body: Record<string, unknown> = {limit, offset};
if (orderBy && orderBy.length > 0) {
body.order_by = orderBy;
}
if (filter && filter.length > 0) {
body.filters = coerceFiltersForWire(filter);
}
if (columns && columns.length > 0) {
body.columns = [...columns];
}
const result = await this.requestJson<QueryResponsePayload>(
`/query_executions/${uuid}:fetch_results`,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
signal,
},
);
// `availableColumnNames` is `:fetch_results`-only, so the shared parser
// stays endpoint-agnostic and only this call site exposes the field.
return {
...parseQueryResponse(result),
availableColumnNames: result.availableColumnNames,
};
}
async cancelQuery(uuid: string, signal?: AbortSignal): Promise<void> {
await this.request(`/query_executions/${uuid}:cancel`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({}),
signal,
});
}
async listQueryExecutions(
signal?: AbortSignal,
): Promise<ReadonlyArray<RawQueryExecution>> {
const result = await this.requestJson<{
queryExecutions?: RawQueryExecution[];
}>('/query_executions', {signal});
return result.queryExecutions ?? [];
}
async deleteQueryExecution(
uuid: string,
signal?: AbortSignal,
): Promise<void> {
await this.request(`/query_executions/${uuid}`, {
method: 'DELETE',
signal,
});
}
// Paginated trace metadata for the current trace source — the data behind
// the Settings-page trace-selection grid. `filter` / `order_by` / `columns`
// mirror `:fetch_results`; `filter` ships as a native JSON array, NOT a
// JSON-encoded string.
async listTraceMetadata(
settings: ReadonlyArray<SettingFilter>,
limit: number,
offset: number,
signal?: AbortSignal,
orderBy?: string,
filter?: ReadonlyArray<Filter>,
columns?: ReadonlyArray<string>,
): Promise<QueryResultPage> {
const body: Record<string, unknown> = {
settings: this.settingsToWire(settings),
limit,
offset,
};
if (orderBy && orderBy.length > 0) {
body.order_by = orderBy;
}
if (filter && filter.length > 0) {
body.filters = coerceFiltersForWire(filter);
}
if (columns && columns.length > 0) {
body.columns = [...columns];
}
const result = await this.requestJson<QueryResponsePayload>(
'/trace_metadata',
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
signal,
},
);
return parseQueryResponse(result);
}
// Column catalog for `/trace_metadata`, fetched once on Settings-page load
// to build the grid's schema + the column-picker. `settings` lets the
// response vary when the schema depends on the trace source.
async listTraceMetadataSchema(
settings: ReadonlyArray<SettingFilter>,
signal?: AbortSignal,
): Promise<TracesSchemaResponse> {
return this.requestJson<TracesSchemaResponse>('/trace_metadata_schema', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
settings: this.settingsToWire(settings),
}),
signal,
});
}
// ----- Internals -----
private async executeAt(
path: string,
query: string,
limit: number,
settings: ReadonlyArray<SettingFilter>,
signal: AbortSignal | undefined,
options?: ExecuteOptions,
): Promise<QueryResultPage> {
const body: Record<string, unknown> = {
limit,
perfetto_sql: query,
settings: this.settingsToWire(settings),
};
// Each trace-selection field rides only when non-default.
if (options?.traceFilters && options.traceFilters.length > 0) {
body.trace_filters = coerceFiltersForWire(options.traceFilters);
}
if (
options?.traceMetadataColumns &&
options.traceMetadataColumns.length > 0
) {
body.trace_metadata_columns = [...options.traceMetadataColumns];
}
if (options?.traceOrderBy && options.traceOrderBy.length > 0) {
body.trace_order_by = options.traceOrderBy;
}
if (options?.traceLimit !== undefined && options.traceLimit > 0) {
body.trace_limit = options.traceLimit;
}
const result = await this.requestJson<QueryResponsePayload>(path, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
signal,
});
return parseQueryResponse(result);
}
// camelCase SettingFilter[] -> the snake_case {setting_id, values, category}
// shape every POST body that ships settings uses (/trace_metadata,
// /trace_metadata_schema, /execute_*).
private settingsToWire(settings: ReadonlyArray<SettingFilter>) {
return settings.map((s) => ({
setting_id: s.settingId,
values: s.values,
category: s.category,
}));
}
private async request(path: string, init?: RequestInit): Promise<Response> {
let response: Response;
try {
response = await fetch(`${this.endpoint}${path}`, {
credentials: 'include',
mode: 'cors',
...init,
});
} catch (e) {
// AbortSignal → DOMException, surface as our typed error.
if (e instanceof DOMException && e.name === 'AbortError') {
throw new QueryCancelledError();
}
throw e;
}
if (!response.ok) {
const errorText = await response
.text()
.catch(() => 'Failed to read response body');
// Surface backend `detail` field; fall back to raw body.
let detail = errorText;
try {
const parsed = JSON.parse(errorText);
if (typeof parsed?.detail === 'string') {
detail = parsed.detail;
}
} catch {
// Not JSON — use the body as-is.
}
if (response.status === 404) {
// Extract UUID from /query_executions/{uuid}[:action]; else use path.
const m = path.match(/\/query_executions\/([^/:?#]+)/);
throw new QueryNotFoundError(m ? m[1] : path);
}
if (response.status === 403) {
throw new Error(
`HTTP error! status: ${response.status}, message: ${detail}. ` +
`This might be an authentication issue. Please ensure you ` +
`are logged in with the correct credentials.`,
);
}
throw new Error(
`HTTP error! status: ${response.status}, message: ${detail}`,
);
}
return response;
}
private async requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await this.request(path, init);
return (await response.json()) as T;
}
}
// Passes wire values through as-is: no numeric coercion (would corrupt 64-bit
// ids/timestamps past 2^53), and SQL NULL arrives as JSON null. Do NOT
// special-case the literal string "NULL" — that would corrupt a genuine "NULL"
// string value into SQL NULL.
export function parseQueryResponse(
result: QueryResponsePayload,
): QueryResultPage {
const colNames = result.columnNames;
if (
colNames === undefined ||
colNames === null ||
result.rows === undefined ||
result.rows === null
) {
return {rows: [], columns: [], queryUuid: result.queryUuid};
}
const columns = colNames.filter((h): h is string => h !== null);
const rows = result.rows.map((row) => {
const out: DataGridRow = {};
for (let i = 0; i < colNames.length; i++) {
const header = colNames[i];
if (header === null) continue;
out[header] = row.values[i];
}
return out;
});
return {
rows,
columns,
queryUuid: result.queryUuid,
totalFilteredRows: result.totalFilteredRows,
};
}