diff --git a/snuba/admin/auth_roles.py b/snuba/admin/auth_roles.py index 4bb7bbfbc6..98d039cbf2 100644 --- a/snuba/admin/auth_roles.py +++ b/snuba/admin/auth_roles.py @@ -79,6 +79,7 @@ class InteractToolAction(ToolAction): TOOL_RESOURCES = { "snql-to-sql": ToolResource("snql-to-sql"), "tracing": ToolResource("tracing"), + "cardinality-analyzer": ToolResource("cardinality-analyzer"), "all": ToolResource("all"), } @@ -176,6 +177,10 @@ def generate_tool_test_role(tool: str) -> Role: ) }, ), + "CardinalityAnalyzer": Role( + name="cardinality-analyzer", + actions={InteractToolAction([TOOL_RESOURCES["cardinality-analyzer"]])}, + ), } DEFAULT_ROLES = [ diff --git a/snuba/admin/cardinality_analyzer/__init__.py b/snuba/admin/cardinality_analyzer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/snuba/admin/cardinality_analyzer/cardinality_analyzer.py b/snuba/admin/cardinality_analyzer/cardinality_analyzer.py new file mode 100644 index 0000000000..c06d069657 --- /dev/null +++ b/snuba/admin/cardinality_analyzer/cardinality_analyzer.py @@ -0,0 +1,48 @@ +from snuba.admin.audit_log.querylog import audit_log +from snuba.admin.clickhouse.common import ( + get_ro_query_node_connection, + validate_ro_query, +) +from snuba.clickhouse.native import ClickhouseResult +from snuba.clusters.cluster import ClickhouseClientSettings +from snuba.datasets.schemas.tables import TableSchema +from snuba.datasets.storages.factory import get_storage +from snuba.datasets.storages.storage_key import StorageKey + +# HACK (VOLO): Everything in this file is a hack + + +@audit_log +def run_metrics_query(query: str, user: str) -> ClickhouseResult: + """ + Validates, audit logs, and executes given query against Querylog + table in ClickHouse. `user` param is necessary for audit_log + decorator. + """ + schema = get_storage(StorageKey("generic_metrics_distributions")).get_schema() + assert isinstance(schema, TableSchema) + validate_ro_query( + sql_query=query, + allowed_tables={ + schema.get_table_name(), + "generic_metric_distributions_aggregated_dist", + }, + ) + return __run_query(query) + + +def __run_query(query: str) -> ClickhouseResult: + """ + Runs given Query against metrics distributions in ClickHouse. This function assumes valid + query and does not validate/sanitize query or response data. + """ + connection = get_ro_query_node_connection( + StorageKey("generic_metrics_distributions").value, + ClickhouseClientSettings.TRACING, + ) + + query_result = connection.execute( + query=query, + with_column_types=True, + ) + return query_result diff --git a/snuba/admin/static/api_client.tsx b/snuba/admin/static/api_client.tsx index dabe56d648..bbf05c24d4 100644 --- a/snuba/admin/static/api_client.tsx +++ b/snuba/admin/static/api_client.tsx @@ -25,6 +25,7 @@ import { SnQLRequest, SnQLResult, SnubaDatasetName } from "./snql_to_sql/types"; import { KafkaTopicData } from "./kafka/types"; import { QuerylogRequest, QuerylogResult } from "./querylog/types"; +import { CardinalityQueryRequest, CardinalityQueryResult } from "./cardinality_analyzer/types"; import { AllocationPolicy } from "./capacity_management/types"; @@ -56,6 +57,7 @@ interface Client { getPredefinedQuerylogOptions: () => Promise<[PredefinedQuery]>; getQuerylogSchema: () => Promise; executeQuerylogQuery: (req: QuerylogRequest) => Promise; + executeCardinalityQuery: (req: CardinalityQueryRequest) => Promise; getAllMigrationGroups: () => Promise; runMigration: (req: RunMigrationRequest) => Promise; getAllowedTools: () => Promise; @@ -267,6 +269,20 @@ function Client() { } }); }, + executeCardinalityQuery: (query: CardinalityQueryRequest) => { + const url = baseUrl + "cardinality_query"; + return fetch(url, { + headers: { "Content-Type": "application/json" }, + method: "POST", + body: JSON.stringify(query), + }).then((resp) => { + if (resp.ok) { + return resp.json(); + } else { + return resp.json().then(Promise.reject.bind(Promise)); + } + }); + }, getAllMigrationGroups: () => { const url = baseUrl + "migrations/groups"; return fetch(url, { diff --git a/snuba/admin/static/cardinality_analyzer/index.tsx b/snuba/admin/static/cardinality_analyzer/index.tsx new file mode 100644 index 0000000000..ac54f65d91 --- /dev/null +++ b/snuba/admin/static/cardinality_analyzer/index.tsx @@ -0,0 +1,53 @@ +import React, { useState, useEffect } from "react"; +import Client from "../api_client"; +import { Table } from "../table"; +import QueryDisplay from "./query_display"; +import { CardinalityQueryResult, PredefinedQuery } from "./types"; + +function CardinalityQueries(props: { api: Client }) { + const [predefinedQueryOptions, setPredefinedQueryOptions] = useState< + PredefinedQuery[] + >([]); + + + function tablePopulator(queryResult: CardinalityQueryResult) { + return ( +
+ + + ); + } + + function formatSQL(sql: string) { + const formatted = sql + .split("\n") + .map((line) => line.substring(4, line.length)) + .join("\n"); + return formatted.trim(); + } + + return ( +
+ {QueryDisplay({ + api: props.api, + resultDataPopulator: tablePopulator, + predefinedQueryOptions: [], + })} +
+ ); +} + +const selectStyle = { + marginBottom: 8, + height: 30, +}; + +const scroll = { + overflowX: "scroll" as const, + width: "100%", +}; + +export default CardinalityQueries; diff --git a/snuba/admin/static/cardinality_analyzer/query_display.tsx b/snuba/admin/static/cardinality_analyzer/query_display.tsx new file mode 100644 index 0000000000..48646d7fbb --- /dev/null +++ b/snuba/admin/static/cardinality_analyzer/query_display.tsx @@ -0,0 +1,172 @@ +import React, { useState } from "react"; +import Client from "../api_client"; +import { Collapse } from "../collapse"; +import QueryEditor from "../query_editor"; + +import { CardinalityQueryRequest, CardinalityQueryResult, PredefinedQuery } from "./types"; + +type QueryState = Partial; + +function QueryDisplay(props: { + api: Client; + resultDataPopulator: (queryResult: CardinalityQueryResult) => JSX.Element; + predefinedQueryOptions: Array; +}) { + const [query, setQuery] = useState({}); + const [queryResultHistory, setCardinalityQueryResultHistory] = useState< + CardinalityQueryResult[] + >([]); + + function updateQuerySql(sql: string) { + setQuery((prevQuery) => { + return { + ...prevQuery, + sql, + }; + }); + } + + function executeQuery() { + props.api + .executeCardinalityQuery(query as CardinalityQueryRequest) + .then((result) => { + result.input_query = query.sql || ""; + setCardinalityQueryResultHistory((prevHistory) => [result, ...prevHistory]); + }) + .catch((err) => { + console.log("ERROR", err); + window.alert("An error occurred: " + err.error.message); + }); + } + + function convertResultsToCSV(queryResult: CardinalityQueryResult) { + let output = queryResult.column_names.join(","); + for (const row of queryResult.rows) { + const escaped = row.map((v) => + typeof v == "string" && v.includes(",") ? '"' + v + '"' : v + ); + output = output + "\n" + escaped.join(","); + } + return output; + } + + function copyText(queryResult: CardinalityQueryResult, format: string) { + const formatter = format == "csv" ? convertResultsToCSV : JSON.stringify; + window.navigator.clipboard.writeText(formatter(queryResult)); + } + + return ( +
+

Construct a Metrics Query

+ { + updateQuerySql(sql); + }} + predefinedQueryOptions={props.predefinedQueryOptions} + /> +
+
+ +
+
+
+

Query results

+ {queryResultHistory.map((queryResult, idx) => { + if (idx === 0) { + return ( +
+

{queryResult.input_query}

+

+ +

+

+ +

+ {props.resultDataPopulator(queryResult)} +
+ ); + } + + return ( + + + + {props.resultDataPopulator(queryResult)} + + ); + })} +
+
+ ); +} + +const executeActionsStyle = { + display: "flex", + justifyContent: "space-between", + marginTop: 8, +}; + +const executeButtonStyle = { + height: 30, + border: 0, + padding: "4px 20px", + marginRight: 10, +}; + +const selectStyle = { + marginRight: 8, + height: 30, +}; + +function TextArea(props: { + value: string; + onChange: (nextValue: string) => void; +}) { + const { value, onChange } = props; + return ( +