diff --git a/console/src/api/materialize/query-history/__snapshots__/statementLoggingMaxSampleRate.test.ts.snap b/console/src/api/materialize/query-history/__snapshots__/statementLoggingMaxSampleRate.test.ts.snap new file mode 100644 index 0000000000000..3327f0cf0910b --- /dev/null +++ b/console/src/api/materialize/query-history/__snapshots__/statementLoggingMaxSampleRate.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`queries > buildStatementLoggingMaxSampleRateQuery produces the expected query 1`] = ` +{ + "parameters": [], + "sql": "SHOW statement_logging_max_sample_rate", +} +`; diff --git a/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.test.ts b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.test.ts new file mode 100644 index 0000000000000..0b8df14f8952d --- /dev/null +++ b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.test.ts @@ -0,0 +1,20 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { queryBuilder } from "~/api/materialize"; + +import { buildStatementLoggingMaxSampleRateQuery } from "./statementLoggingMaxSampleRate"; + +describe("queries", () => { + it("buildStatementLoggingMaxSampleRateQuery produces the expected query", () => { + const { sql, parameters } = + buildStatementLoggingMaxSampleRateQuery().compile(queryBuilder); + expect({ sql, parameters }).toMatchSnapshot(); + }); +}); diff --git a/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.ts b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.ts new file mode 100644 index 0000000000000..b000ae0c5cd54 --- /dev/null +++ b/console/src/api/materialize/query-history/statementLoggingMaxSampleRate.ts @@ -0,0 +1,47 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { QueryKey } from "@tanstack/react-query"; +import { sql } from "kysely"; + +import { executeSqlV2, queryBuilder } from "~/api/materialize"; + +export const buildStatementLoggingMaxSampleRateQuery = () => { + return sql<{ + statement_logging_max_sample_rate: string; + }>`SHOW statement_logging_max_sample_rate`; +}; + +/** + * Fetches the system-wide cap on the statement logging sample rate. The effective rate is + * `min(session statement_logging_sample_rate, this)`, so a value of `0` means statement + * logging is off for everyone and query history can never have rows. + * + * Returns `null` if the variable could not be read as a number. + */ +export default async function fetchStatementLoggingMaxSampleRate({ + queryKey, + requestOptions, +}: { + queryKey: QueryKey; + requestOptions: RequestInit; +}) { + const compiledQuery = + buildStatementLoggingMaxSampleRateQuery().compile(queryBuilder); + + const response = await executeSqlV2({ + queries: compiledQuery, + queryKey, + requestOptions, + }); + + const rate = parseFloat(response.rows[0]?.statement_logging_max_sample_rate); + + return Number.isFinite(rate) ? rate : null; +} diff --git a/console/src/platform/query-history/QueryHistoryList.test.tsx b/console/src/platform/query-history/QueryHistoryList.test.tsx index 81820e3fd21af..435f3664ed91c 100644 --- a/console/src/platform/query-history/QueryHistoryList.test.tsx +++ b/console/src/platform/query-history/QueryHistoryList.test.tsx @@ -66,9 +66,15 @@ const useFetchQueryHistoryUsersColumns: Array = [ buildColumn({ name: "email" }), ]; +// The schema defaults `dateRange` to a window ending at `new Date()`, so handlers must +// reuse the same parsed filters the component is rendered with in order to match. +const PARSED_DEFAULT_SCHEMA_VALUES = queryHistoryListSchema.parse( + DEFAULT_SCHEMA_VALUES, +); + const DEFAULT_FETCH_QUERY_LIST_HANDLER = buildSqlQueryHandlerV2({ queryKey: queryHistoryQueryKeys.list({ - filters: queryHistoryListSchema.parse(DEFAULT_SCHEMA_VALUES), + filters: PARSED_DEFAULT_SCHEMA_VALUES, isRedacted: false, isV0_132_0: false, }), @@ -94,9 +100,13 @@ const DEFAULT_FETCH_QUERY_HISTORY_USERS_HANDLER = buildSqlQueryHandlerV2({ }), }); -const PARSED_DEFAULT_SCHEMA_VALUES = queryHistoryListSchema.parse( - DEFAULT_SCHEMA_VALUES, -); +const buildMaxSampleRateHandler = (rate: string) => + buildSqlQueryHandlerV2({ + queryKey: queryHistoryQueryKeys.statementLoggingMaxSampleRate(), + results: mapKyselyToTabular({ + rows: [{ statement_logging_max_sample_rate: rate }], + }), + }); const ALL_COLUMNS = COLUMNS.map(({ key }) => key); @@ -127,6 +137,7 @@ describe("QueryHistoryList", () => { server.use(DEFAULT_FETCH_QUERY_LIST_HANDLER); server.use(DEFAULT_FETCH_CLUSTER_LIST_HANDLER); server.use(DEFAULT_FETCH_QUERY_HISTORY_USERS_HANDLER); + server.use(buildMaxSampleRateHandler("0.99")); }); afterEach(() => { @@ -387,6 +398,47 @@ describe("QueryHistoryList", () => { expect(await screen.findByText("No results found.")).toBeVisible(); }); + it("Should show the filter-oriented empty state when sampling is enabled", async () => { + await renderComponent( + , + { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", healthyEnvironment), + }, + ); + + expect(await screen.findByText("No results found.")).toBeVisible(); + expect( + screen.queryByText("Statement logging is turned off."), + ).not.toBeInTheDocument(); + }); + + it("Should show a sampling disabled empty state when the max sample rate is zero", async () => { + server.use(buildMaxSampleRateHandler("0")); + + await renderComponent( + , + { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", healthyEnvironment), + }, + ); + + expect( + await screen.findByText("Statement logging is turned off."), + ).toBeVisible(); + expect( + screen.getByText(/ALTER SYSTEM SET statement_logging_max_sample_rate/), + ).toBeVisible(); + expect(screen.queryByText("No results found.")).not.toBeInTheDocument(); + }); + // TODO (robinclowers): Fix and renenable this https://github.com/MaterializeInc/console/issues/2482 it.skip( "Should show an error state when we fail to fetch the query history list", diff --git a/console/src/platform/query-history/QueryHistoryList.tsx b/console/src/platform/query-history/QueryHistoryList.tsx index 4f7510a784543..79502e3aa9432 100644 --- a/console/src/platform/query-history/QueryHistoryList.tsx +++ b/console/src/platform/query-history/QueryHistoryList.tsx @@ -45,7 +45,10 @@ import ClusterFilter from "./ClusterFilter"; import ColumnFilter from "./ColumnFilter"; import DateRangeInput from "./DateRangeInput"; import FilterMenu from "./FilterMenu"; -import { useFetchQueryHistoryList } from "./queries"; +import { + useFetchQueryHistoryList, + useFetchStatementLoggingMaxSampleRate, +} from "./queries"; import QueryHistoryTable from "./QueryHistoryTable"; import { formatSelectedDates, @@ -104,6 +107,34 @@ const EmptyState = () => { ); }; +const SamplingDisabledState = () => { + const { colors } = useTheme(); + + return ( + + + + + + + The statement logging sample rate is set to zero, so no queries + are recorded. Ask an administrator to raise it with{" "} + + ALTER SYSTEM SET statement_logging_max_sample_rate + + . In self-managed deployments it can also be set through the + Materialize operator's Helm chart values. + + } + /> + + + ); +}; + export const UnauthorizedState = () => { const { colors } = useTheme(); return ( @@ -189,12 +220,23 @@ export const QueryHistoryList = ({ }, ); + const isEmpty = queryHistoryListData?.rows.length === 0; + + // Only needed to disambiguate an empty result set, so keep it off the path + // that renders rows. + const { data: maxSampleRate, isLoading: isMaxSampleRateLoading } = + useFetchStatementLoggingMaxSampleRate({ enabled: isEmpty }); + useSyncObjectToSearchParams(urlParamObject); const isError = isPrivilegesError || isQueryHistoryListError; - const isLoading = isPrivilegesLoading || isQueryHistoryListLoading; - const isEmpty = queryHistoryListData?.rows.length === 0; + const isLoading = + isPrivilegesLoading || isQueryHistoryListLoading || isMaxSampleRateLoading; + const isSamplingDisabled = isEmpty && maxSampleRate === 0; + // Sampling being off, not an over-narrow filter, is what the user has to fix, + // so don't draw attention to the filter controls. + const isFilterEmpty = isEmpty && !isSamplingDisabled; const isUnauthorized = isPrivilegesSuccess && !isAuthorized; @@ -215,12 +257,14 @@ export const QueryHistoryList = ({ @@ -246,7 +290,9 @@ export const QueryHistoryList = ({ > - ) : isEmpty ? ( + ) : isSamplingDisabled ? ( + + ) : isFilterEmpty ? ( ) : ( [...queryHistoryQueryKeys.all(), buildQueryKeyPart("users")] as const, + statementLoggingMaxSampleRate: () => + [ + ...queryHistoryQueryKeys.all(), + buildQueryKeyPart("statement-logging-max-sample-rate"), + ] as const, detail: ({ executionId }: { executionId: string }) => [ ...queryHistoryQueryKeys.all(), @@ -133,6 +139,21 @@ export function useFetchQueryHistoryUsers() { }); } +export function useFetchStatementLoggingMaxSampleRate(options?: { + enabled?: boolean; +}) { + return useQuery({ + queryKey: queryHistoryQueryKeys.statementLoggingMaxSampleRate(), + queryFn: ({ queryKey, signal }) => { + return fetchStatementLoggingMaxSampleRate({ + queryKey, + requestOptions: { signal }, + }); + }, + enabled: options?.enabled, + }); +} + export function useFetchQueryHistoryStatementInfo( parameters: QueryHistoryStatementInfoParameters, options?: { enabled?: boolean },