diff --git a/static/app/components/modals/explore/saveQueryModal.spec.tsx b/static/app/components/modals/explore/saveQueryModal.spec.tsx index e163a106857bf9..288f33165f3813 100644 --- a/static/app/components/modals/explore/saveQueryModal.spec.tsx +++ b/static/app/components/modals/explore/saveQueryModal.spec.tsx @@ -3,6 +3,7 @@ import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrar import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import SaveQueryModal from 'sentry/components/modals/explore/saveQueryModal'; +import {TraceItemDataset} from 'sentry/views/explore/types'; const stubEl = (props: {children?: React.ReactNode}) =>
{props.children}
; @@ -24,6 +25,7 @@ describe('SaveQueryModal', function () { closeModal={() => {}} organization={initialData.organization} saveQuery={saveQuery} + traceItemDataset={TraceItemDataset.SPANS} /> ); @@ -45,6 +47,7 @@ describe('SaveQueryModal', function () { closeModal={() => {}} organization={initialData.organization} saveQuery={saveQuery} + traceItemDataset={TraceItemDataset.SPANS} /> ); @@ -69,6 +72,7 @@ describe('SaveQueryModal', function () { closeModal={() => {}} organization={initialData.organization} saveQuery={saveQuery} + traceItemDataset={TraceItemDataset.SPANS} /> ); @@ -95,6 +99,28 @@ describe('SaveQueryModal', function () { organization={initialData.organization} saveQuery={saveQuery} name="Initial Query Name" + traceItemDataset={TraceItemDataset.SPANS} + /> + ); + + expect(screen.getByRole('textbox')).toHaveValue('Initial Query Name'); + expect(screen.getByText('Rename Query')).toBeInTheDocument(); + expect(screen.getByText('Save Changes')).toBeInTheDocument(); + }); + + it('should render ui with logs dataset', function () { + const saveQuery = jest.fn(); + render( + {}} + organization={initialData.organization} + saveQuery={saveQuery} + name="Initial Query Name" + traceItemDataset={TraceItemDataset.LOGS} /> ); diff --git a/static/app/components/modals/explore/saveQueryModal.tsx b/static/app/components/modals/explore/saveQueryModal.tsx index 49d8cc8928131c..56fdef54ad089d 100644 --- a/static/app/components/modals/explore/saveQueryModal.tsx +++ b/static/app/components/modals/explore/saveQueryModal.tsx @@ -18,11 +18,14 @@ import type {Organization, SavedQuery} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import useOrganization from 'sentry/utils/useOrganization'; +import {useSetLogsSavedQueryInfo} from 'sentry/views/explore/contexts/logs/logsPageParams'; import {useSetExplorePageParams} from 'sentry/views/explore/contexts/pageParamsContext'; +import {TraceItemDataset} from 'sentry/views/explore/types'; export type SaveQueryModalProps = { organization: Organization; saveQuery: (name: string, starred?: boolean) => Promise; + traceItemDataset: TraceItemDataset; name?: string; source?: 'toolbar' | 'table'; }; @@ -37,6 +40,7 @@ function SaveQueryModal({ saveQuery, name: initialName, source, + traceItemDataset, }: Props) { const organization = useOrganization(); @@ -45,12 +49,17 @@ function SaveQueryModal({ const [starred, setStarred] = useState(true); const setExplorePageParams = useSetExplorePageParams(); + const setLogsQuery = useSetLogsSavedQueryInfo(); const updatePageIdAndTitle = useCallback( (id: string, title: string) => { - setExplorePageParams({id, title}); + if (traceItemDataset === TraceItemDataset.LOGS) { + setLogsQuery(id, title); + } else if (traceItemDataset === TraceItemDataset.SPANS) { + setExplorePageParams({id, title}); + } }, - [setExplorePageParams] + [setExplorePageParams, setLogsQuery, traceItemDataset] ); const onSave = useCallback(async () => { @@ -63,12 +72,21 @@ function SaveQueryModal({ } addSuccessMessage(t('Query saved successfully')); if (defined(source)) { - trackAnalytics('trace_explorer.save_query_modal', { - action: 'submit', - save_type: initialName === undefined ? 'save_new_query' : 'rename_query', - ui_source: source, - organization, - }); + if (traceItemDataset === TraceItemDataset.LOGS) { + trackAnalytics('logs.save_query_modal', { + action: 'submit', + save_type: initialName === undefined ? 'save_new_query' : 'rename_query', + ui_source: source, + organization, + }); + } else if (traceItemDataset === TraceItemDataset.SPANS) { + trackAnalytics('trace_explorer.save_query_modal', { + action: 'submit', + save_type: initialName === undefined ? 'save_new_query' : 'rename_query', + ui_source: source, + organization, + }); + } } closeModal(); } catch (error) { @@ -86,6 +104,7 @@ function SaveQueryModal({ organization, initialName, source, + traceItemDataset, ]); return ( diff --git a/static/app/utils/analytics/logsAnalyticsEvent.tsx b/static/app/utils/analytics/logsAnalyticsEvent.tsx index 41f387231f70aa..9e506ee4c71629 100644 --- a/static/app/utils/analytics/logsAnalyticsEvent.tsx +++ b/static/app/utils/analytics/logsAnalyticsEvent.tsx @@ -35,6 +35,15 @@ export type LogsAnalyticsEventParameters = { 'logs.issue_details.drawer_opened': { organization: Organization; }; + 'logs.save_as': { + save_type: 'alert' | 'dashboard' | 'update_query'; + ui_source: 'toolbar' | 'chart' | 'compare chart' | 'searchbar'; + }; + 'logs.save_query_modal': { + action: 'open' | 'submit'; + save_type: 'save_new_query' | 'rename_query'; + ui_source: 'toolbar' | 'table'; + }; 'logs.table.row_expanded': { log_id: string; page_source: LogsAnalyticsPageSource; @@ -50,4 +59,6 @@ export const logsAnalyticsEventMap: Record 'logs.explorer.metadata': 'Log Explorer Pageload Metadata', 'logs.issue_details.drawer_opened': 'Issues Page Logs Drawer Opened', 'logs.table.row_expanded': 'Expanded Log Row Details', + 'logs.save_as': 'Logs Save As', + 'logs.save_query_modal': 'Logs Save Query Modal', }; diff --git a/static/app/views/explore/contexts/logs/logsPageParams.tsx b/static/app/views/explore/contexts/logs/logsPageParams.tsx index 0cb62d459534ae..bf6a49b3b04464 100644 --- a/static/app/views/explore/contexts/logs/logsPageParams.tsx +++ b/static/app/views/explore/contexts/logs/logsPageParams.tsx @@ -89,17 +89,26 @@ interface LogsPageParams { */ readonly groupBy?: string; + /** + * The id of the query, if a saved query. + */ + readonly id?: string; /** * If provided, add a 'trace:{trace id}' to all queries. * Used in embedded views like error page and trace page. * Can be an array of trace IDs on some pages (eg. replays) */ readonly limitToTraceId?: string | string[]; + /** * If provided, ignores the project in the location and uses the provided project IDs. * Useful for cross-project traces when project is in the location. */ readonly projectIds?: number[]; + /** + * The title of the query, if a saved query. + */ + readonly title?: string; } type NullablePartial = { @@ -223,7 +232,7 @@ export function LogsPageParamsProvider({ ); } -const useLogsPageParams = _useLogsPageParams; +export const useLogsPageParams = _useLogsPageParams; const decodeLogsQuery = (location: Location): string => { if (!location.query?.[LOGS_QUERY_KEY]) { @@ -440,6 +449,16 @@ export function useSetLogsFields() { ); } +export function useSetLogsSavedQueryInfo() { + const setPageParams = useSetLogsPageParams(); + return useCallback( + (id: string, title: string) => { + setPageParams({id, title}); + }, + [setPageParams] + ); +} + interface ToggleableSortBy { field: string; defaultDirection?: 'asc' | 'desc'; // Defaults to descending if not provided. diff --git a/static/app/views/explore/hooks/useGetSavedQueries.tsx b/static/app/views/explore/hooks/useGetSavedQueries.tsx index c452e34f97e16d..ba4a2696bf90e8 100644 --- a/static/app/views/explore/hooks/useGetSavedQueries.tsx +++ b/static/app/views/explore/hooks/useGetSavedQueries.tsx @@ -1,10 +1,12 @@ import {useCallback, useMemo} from 'react'; +import type {DateString} from 'sentry/types/core'; import type {User} from 'sentry/types/user'; import {defined} from 'sentry/utils'; import {useApiQuery, useQueryClient} from 'sentry/utils/queryClient'; import useOrganization from 'sentry/utils/useOrganization'; import type {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import {TraceItemDataset} from 'sentry/views/explore/types'; export type RawGroupBy = { groupBy: string; @@ -41,7 +43,8 @@ type ReadableQuery = { visualize?: RawVisualize[]; }; -class Query { +// This is the `query` property on our SavedQuery, which indicates the actualy query portion of the saved query, hence SavedQueryQuery. +export class SavedQueryQuery { fields: string[]; mode: Mode; orderby: string; @@ -86,6 +89,7 @@ export type SortOption = // Comes from ExploreSavedQueryModelSerializer type ReadableSavedQuery = { + dataset: 'logs' | 'spans' | 'segment_spans'; // ExploreSavedQueryDataset dateAdded: string; dateUpdated: string; id: number; @@ -95,7 +99,6 @@ type ReadableSavedQuery = { position: number | null; projects: number[]; query: [ReadableQuery, ...ReadableQuery[]]; - queryDataset: string; starred: boolean; createdBy?: User; end?: string; @@ -114,15 +117,15 @@ export class SavedQuery { name: string; position: number | null; projects: number[]; - query: [Query, ...Query[]]; - queryDataset: string; + query: [SavedQueryQuery, ...SavedQueryQuery[]]; + dataset: ReadableSavedQuery['dataset']; starred: boolean; createdBy?: User; - end?: string; + end?: string | DateString; environment?: string[]; isPrebuilt?: boolean; range?: string; - start?: string; + start?: string | DateString; constructor(savedQuery: ReadableSavedQuery) { this.dateAdded = savedQuery.dateAdded; @@ -134,10 +137,9 @@ export class SavedQuery { this.position = savedQuery.position; this.projects = savedQuery.projects; this.query = [ - new Query(savedQuery.query[0]), - ...savedQuery.query.slice(1).map(q => new Query(q)), + new SavedQueryQuery(savedQuery.query[0]), + ...savedQuery.query.slice(1).map(q => new SavedQueryQuery(q)), ]; - this.queryDataset = savedQuery.queryDataset; this.starred = savedQuery.starred; this.createdBy = savedQuery.createdBy; this.end = savedQuery.end; @@ -145,9 +147,14 @@ export class SavedQuery { this.isPrebuilt = savedQuery.isPrebuilt; this.range = savedQuery.range; this.start = savedQuery.start; + this.dataset = savedQuery.dataset; } } +export function getSavedQueryTraceItemDataset(dataset: ReadableSavedQuery['dataset']) { + return DATASET_TO_TRACE_ITEM_DATASET_MAP[dataset]; +} + type Props = { cursor?: string; exclude?: 'owned' | 'shared'; @@ -226,3 +233,22 @@ export function useInvalidateSavedQuery(id?: string) { }); }, [queryClient, organization.slug, id]); } + +const DATASET_LABEL_MAP: Record = { + logs: 'Logs', + spans: 'Traces', + segment_spans: 'Traces', +}; + +const DATASET_TO_TRACE_ITEM_DATASET_MAP: Record< + ReadableSavedQuery['dataset'], + TraceItemDataset +> = { + logs: TraceItemDataset.LOGS, + spans: TraceItemDataset.SPANS, + segment_spans: TraceItemDataset.SPANS, +}; + +export function getSavedQueryDatasetLabel(dataset: ReadableSavedQuery['dataset']) { + return DATASET_LABEL_MAP[dataset]; +} diff --git a/static/app/views/explore/hooks/useSaveQuery.tsx b/static/app/views/explore/hooks/useSaveQuery.tsx index b4990fb77452cd..5e1c5746789e3c 100644 --- a/static/app/views/explore/hooks/useSaveQuery.tsx +++ b/static/app/views/explore/hooks/useSaveQuery.tsx @@ -1,14 +1,17 @@ import {useCallback, useMemo} from 'react'; +import type {DateString} from 'sentry/types/core'; import {encodeSort} from 'sentry/utils/discover/eventView'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; +import {useLogsPageParams} from 'sentry/views/explore/contexts/logs/logsPageParams'; import {useExplorePageParams} from 'sentry/views/explore/contexts/pageParamsContext'; import { isGroupBy, isVisualize, } from 'sentry/views/explore/contexts/pageParamsContext/aggregateFields'; +import type {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval'; import { type SavedQuery, @@ -16,76 +19,78 @@ import { useInvalidateSavedQuery, } from 'sentry/views/explore/hooks/useGetSavedQueries'; -const TRACE_EXPLORER_DATASET = 'spans'; +// Request payload type that matches the backend ExploreSavedQuerySerializer +type ExploreSavedQueryRequest = { + dataset: 'logs' | 'spans' | 'segment_spans'; + name: string; + projects: number[]; + end?: DateString; + environment?: string[]; + interval?: string; + query?: Array<{ + mode: Mode; + aggregateField?: Array<{groupBy: string} | {yAxes: string[]; chartType?: number}>; + aggregateOrderby?: string; + fields?: string[]; + groupby?: string[]; + orderby?: string; + query?: string; + visualize?: Array<{ + yAxes: string[]; + chartType?: number; + }>; + }>; + range?: string; + start?: DateString; +}; -export function useSaveQuery() { - const {aggregateFields, sortBys, fields, query, mode, id, title} = - useExplorePageParams(); - const {selection} = usePageFilters(); - const {datetime, projects, environments} = selection; - const {start, end, period} = datetime; +export function useSpansSaveQuery() { + const pageFilters = usePageFilters(); const [interval] = useChartInterval(); + const exploreParams = useExplorePageParams(); + const {id, title} = exploreParams; + + const {saveQueryFromSavedQuery, updateQueryFromSavedQuery} = useFromSavedQuery(); + + const requestData = useMemo((): ExploreSavedQueryRequest => { + return convertExplorePageParamsToRequest( + exploreParams, + pageFilters, + interval, + title ?? '' + ); + }, [exploreParams, pageFilters, interval, title]); + + const {saveQueryApi, updateQueryApi} = useCreateOrUpdateSavedQuery(id); + + const saveQuery = useCallback( + (newTitle: string, starred = true) => { + return saveQueryApi({...requestData, name: newTitle}, starred); + }, + [saveQueryApi, requestData] + ); + const updateQuery = useCallback(() => { + return updateQueryApi(requestData); + }, [updateQueryApi, requestData]); + + return {saveQuery, updateQuery, saveQueryFromSavedQuery, updateQueryFromSavedQuery}; +} + +function useCreateOrUpdateSavedQuery(id?: string) { const api = useApi(); const organization = useOrganization(); const invalidateSavedQueries = useInvalidateSavedQueries(); const invalidateSavedQuery = useInvalidateSavedQuery(id); - const data = useMemo(() => { - return { - name: title, - dataset: TRACE_EXPLORER_DATASET, // Only supported for trace explorer for now - start, - end, - range: period, - interval, - projects, - environment: environments, - query: [ - { - aggregateField: aggregateFields - .filter(aggregateField => { - if (isGroupBy(aggregateField)) { - return aggregateField.groupBy !== ''; - } - return true; - }) - .map(aggregateField => { - return isVisualize(aggregateField) - ? aggregateField.toJSON() - : aggregateField; - }), - fields, - orderby: sortBys[0] ? encodeSort(sortBys[0]) : undefined, - query: query ?? '', - mode, - }, - ], - }; - }, [ - aggregateFields, - sortBys, - fields, - query, - mode, - start, - end, - period, - interval, - projects, - environments, - title, - ]); - - const saveQuery = useCallback( - async (newTitle: string, starred = true) => { + const saveQueryApi = useCallback( + async (data: ExploreSavedQueryRequest, starred = true) => { const response = await api.requestPromise( `/organizations/${organization.slug}/explore/saved/`, { method: 'POST', data: { ...data, - name: newTitle, starred, }, } @@ -94,21 +99,35 @@ export function useSaveQuery() { invalidateSavedQuery(); return response; }, - [api, organization.slug, data, invalidateSavedQueries, invalidateSavedQuery] + [api, organization.slug, invalidateSavedQueries, invalidateSavedQuery] ); - const updateQuery = useCallback(async () => { - const response = await api.requestPromise( - `/organizations/${organization.slug}/explore/saved/${id}/`, - { - method: 'PUT', - data, - } - ); - invalidateSavedQueries(); - invalidateSavedQuery(); - return response; - }, [api, organization.slug, id, data, invalidateSavedQueries, invalidateSavedQuery]); + const updateQueryApi = useCallback( + async (data: ExploreSavedQueryRequest) => { + const response = await api.requestPromise( + `/organizations/${organization.slug}/explore/saved/${id}/`, + { + method: 'PUT', + data, + } + ); + invalidateSavedQueries(); + invalidateSavedQuery(); + return response; + }, + [api, organization.slug, id, invalidateSavedQueries, invalidateSavedQuery] + ); + + return {saveQueryApi, updateQueryApi}; +} + +/** + * For updating or duplicating queries, agnostic to dataset since it's operating on existing data + */ +export function useFromSavedQuery() { + const api = useApi(); + const organization = useOrganization(); + const invalidateSavedQueries = useInvalidateSavedQueries(); const saveQueryFromSavedQuery = useCallback( async (savedQuery: SavedQuery) => { @@ -144,5 +163,128 @@ export function useSaveQuery() { [api, organization.slug, invalidateSavedQueries] ); + return {saveQueryFromSavedQuery, updateQueryFromSavedQuery}; +} + +export function useLogsSaveQuery() { + const pageFilters = usePageFilters(); + const [interval] = useChartInterval(); + const logsParams = useLogsPageParams(); + const {id, title} = logsParams; + + const {saveQueryFromSavedQuery, updateQueryFromSavedQuery} = useFromSavedQuery(); + + const requestData = useMemo((): ExploreSavedQueryRequest => { + return convertLogsPageParamsToRequest(logsParams, pageFilters, interval, title ?? ''); + }, [logsParams, pageFilters, interval, title]); + + const {saveQueryApi, updateQueryApi} = useCreateOrUpdateSavedQuery(id); + + const saveQuery = useCallback( + (newTitle: string, starred = true) => { + return saveQueryApi({...requestData, name: newTitle}, starred); + }, + [saveQueryApi, requestData] + ); + + const updateQuery = useCallback(() => { + return updateQueryApi(requestData); + }, [updateQueryApi, requestData]); + return {saveQuery, updateQuery, saveQueryFromSavedQuery, updateQueryFromSavedQuery}; } + +function convertExplorePageParamsToRequest( + exploreParams: ReturnType, + pageFilters: ReturnType, + interval: string, + title: string +): ExploreSavedQueryRequest { + const {selection} = pageFilters; + const {datetime, projects, environments} = selection; + const {start, end, period} = datetime; + + const {aggregateFields, sortBys, fields, query, mode} = exploreParams; + + const transformedAggregateFields = aggregateFields + .filter(aggregateField => { + if (isGroupBy(aggregateField)) { + return aggregateField.groupBy !== ''; + } + return true; + }) + .map(aggregateField => { + return isVisualize(aggregateField) + ? { + yAxes: [aggregateField.yAxis], + chartType: aggregateField.chartType, + } + : {groupBy: aggregateField.groupBy}; + }); + + return { + name: title, + projects, + dataset: 'spans', + start, + end, + range: period ?? undefined, + environment: environments, + interval, + query: [ + { + fields, + orderby: sortBys[0] ? encodeSort(sortBys[0]) : undefined, + query: query ?? '', + aggregateField: transformedAggregateFields, + mode, + }, + ], + }; +} + +function convertLogsPageParamsToRequest( + logsParams: ReturnType, + pageFilters: ReturnType, + interval: string, + title: string +): ExploreSavedQueryRequest { + const {selection} = pageFilters; + const {datetime, projects, environments} = selection; + const {start, end, period} = datetime; + + const {sortBys, fields, search, mode, groupBy, aggregateFn, aggregateParam} = + logsParams; + const query = search?.formatString() ?? ''; + + const aggregate = + aggregateFn && aggregateParam ? `${aggregateFn}(${aggregateParam})` : undefined; + const visualize = aggregate + ? [ + { + yAxes: [aggregate], + }, + ] + : undefined; + + return { + name: title, + projects, + dataset: 'logs', + start, + end, + range: period ?? undefined, + environment: environments, + interval, + query: [ + { + fields, + orderby: sortBys[0] ? encodeSort(sortBys[0]) : undefined, + query, + groupby: groupBy ? [groupBy] : undefined, + mode, + visualize, + }, + ], + }; +} diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index dfb3e34d43ab5a..13fef9a8d7aa88 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -1,5 +1,4 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import styled from '@emotion/styled'; import {openModal} from 'sentry/actionCreators/modal'; import Feature from 'sentry/components/acl/feature'; @@ -13,28 +12,9 @@ import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilt import {SearchQueryBuilderProvider} from 'sentry/components/searchQueryBuilder/context'; import {IconChevron, IconTable} from 'sentry/icons'; import {t} from 'sentry/locale'; -import type {NewQuery} from 'sentry/types/organization'; -import {defined} from 'sentry/utils'; -import {trackAnalytics} from 'sentry/utils/analytics'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; -import EventView from 'sentry/utils/discover/eventView'; -import type {Sort} from 'sentry/utils/discover/fields'; -import {parseFunction, prettifyParsedFunction} from 'sentry/utils/discover/fields'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; -import {MutableSearch} from 'sentry/utils/tokenizeSearch'; -import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; -import usePageFilters from 'sentry/utils/usePageFilters'; -import useProjects from 'sentry/utils/useProjects'; -import useRouter from 'sentry/utils/useRouter'; -import {Dataset} from 'sentry/views/alerts/rules/metric/types'; -import { - DashboardWidgetSource, - DEFAULT_WIDGET_NAME, - DisplayType, - WidgetType, -} from 'sentry/views/dashboards/types'; -import {handleAddQueryToDashboard} from 'sentry/views/discover/utils'; import SchemaHintsList, { SchemaHintsSection, } from 'sentry/views/explore/components/schemaHints/schemaHintsList'; @@ -90,6 +70,7 @@ import { } from 'sentry/views/explore/logs/useLogsQuery'; import {useLogsSearchQueryBuilderProps} from 'sentry/views/explore/logs/useLogsSearchQueryBuilderProps'; import {usePersistentLogsPageParameters} from 'sentry/views/explore/logs/usePersistentLogsPageParameters'; +import {useSaveAsItems} from 'sentry/views/explore/logs/useSaveAsItems'; import {useStreamingTimeseriesResult} from 'sentry/views/explore/logs/useStreamingTimeseriesResult'; import {calculateAverageLogsPerSecond} from 'sentry/views/explore/logs/utils'; import { @@ -99,7 +80,6 @@ import { import {ColumnEditorModal} from 'sentry/views/explore/tables/columnEditorModal'; import type {PickableDays} from 'sentry/views/explore/utils'; import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries'; -import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; type LogsTabProps = PickableDays; @@ -367,166 +347,3 @@ export function LogsTabContent({ ); } - -interface UseSaveAsItemsOptions { - aggregate: string; - groupBy: string | undefined; - interval: string; - mode: Mode; - search: MutableSearch; - sortBys: Sort[]; -} - -function useSaveAsItems({ - aggregate, - groupBy, - interval, - mode, - search, - sortBys, -}: UseSaveAsItemsOptions) { - const location = useLocation(); - const router = useRouter(); - const organization = useOrganization(); - const {projects} = useProjects(); - const pageFilters = usePageFilters(); - - const project = - projects.length === 1 - ? projects[0] - : projects.find(p => p.id === `${pageFilters.selection.projects[0]}`); - - const aggregates = useMemo(() => [aggregate], [aggregate]); - - const saveAsAlert = useMemo(() => { - const alertsUrls = aggregates.map((yAxis: string, index: number) => { - const func = parseFunction(yAxis); - const label = func ? prettifyParsedFunction(func) : yAxis; - return { - key: `${yAxis}-${index}`, - label, - to: getAlertsUrl({ - project, - query: search.formatString(), - pageFilters: pageFilters.selection, - aggregate: yAxis, - organization, - dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, - interval, - eventTypes: 'trace_item_log', - }), - onAction: () => { - trackAnalytics('logs.save_as', { - save_type: 'alert', - ui_source: 'searchbar', - organization, - }); - }, - }; - }); - - return { - key: 'create-alert', - label: t('An Alert for'), - textValue: t('An Alert for'), - children: alertsUrls ?? [], - disabled: !alertsUrls || alertsUrls.length === 0, - isSubmenu: true, - }; - }, [aggregates, interval, organization, pageFilters, project, search]); - - const saveAsDashboard = useMemo(() => { - const dashboardsUrls = aggregates.map((yAxis: string, index: number) => { - const func = parseFunction(yAxis); - const label = func ? prettifyParsedFunction(func) : yAxis; - - return { - key: String(index), - label, - onAction: () => { - trackAnalytics('logs.save_as', { - save_type: 'dashboard', - ui_source: 'searchbar', - organization, - }); - - const fields = - mode === Mode.SAMPLES - ? [] - : [...new Set([groupBy, yAxis, ...sortBys.map(sort => sort.field)])].filter( - defined - ); - - const discoverQuery: NewQuery = { - name: DEFAULT_WIDGET_NAME, - fields, - orderby: sortBys.map(formatSort), - query: search.formatString(), - version: 2, - dataset: DiscoverDatasets.OURLOGS, - yAxis: [yAxis], - }; - - const eventView = EventView.fromNewQueryWithPageFilters( - discoverQuery, - pageFilters.selection - ); - // the chart currently track the chart type internally so force bar type for now - eventView.display = DisplayType.BAR; - - handleAddQueryToDashboard({ - organization, - location, - eventView, - router, - yAxis: eventView.yAxis, - widgetType: WidgetType.LOGS, - source: DashboardWidgetSource.LOGS, - }); - }, - }; - }); - - return { - key: 'add-to-dashboard', - label: ( - {t('A Dashboard widget')}} - > - {t('A Dashboard widget')} - - ), - textValue: t('A Dashboard widget'), - children: dashboardsUrls, - disabled: !dashboardsUrls || dashboardsUrls.length === 0, - isSubmenu: true, - }; - }, [ - aggregates, - groupBy, - mode, - organization, - pageFilters, - search, - sortBys, - location, - router, - ]); - - return useMemo(() => { - const saveAs = []; - if (organization.features.includes('ourlogs-alerts')) { - saveAs.push(saveAsAlert); - } - if (organization.features.includes('ourlogs-dashboards')) { - saveAs.push(saveAsDashboard); - } - return saveAs; - }, [organization, saveAsAlert, saveAsDashboard]); -} - -const DisabledText = styled('span')` - color: ${p => p.theme.disabled}; -`; diff --git a/static/app/views/explore/logs/useSaveAsItems.spec.tsx b/static/app/views/explore/logs/useSaveAsItems.spec.tsx new file mode 100644 index 00000000000000..f6481073f621a9 --- /dev/null +++ b/static/app/views/explore/logs/useSaveAsItems.spec.tsx @@ -0,0 +1,189 @@ +import {LocationFixture} from 'sentry-fixture/locationFixture'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {PageFiltersFixture} from 'sentry-fixture/pageFilters'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {makeTestQueryClient} from 'sentry-test/queryClient'; +import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary'; + +import * as modal from 'sentry/actionCreators/modal'; +import ProjectsStore from 'sentry/stores/projectsStore'; +import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; +import {QueryClientProvider} from 'sentry/utils/queryClient'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import usePageFilters from 'sentry/utils/usePageFilters'; +import {LogsPageParamsProvider} from 'sentry/views/explore/contexts/logs/logsPageParams'; +import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import {useSaveAsItems} from 'sentry/views/explore/logs/useSaveAsItems'; +import {OrganizationContext} from 'sentry/views/organizationContext'; + +jest.mock('sentry/utils/useLocation'); +jest.mock('sentry/utils/useNavigate'); +jest.mock('sentry/utils/usePageFilters'); +jest.mock('sentry/utils/useRouter'); +jest.mock('sentry/actionCreators/modal'); + +const mockedUseLocation = jest.mocked(useLocation); +const mockUseNavigate = jest.mocked(useNavigate); +const mockUsePageFilters = jest.mocked(usePageFilters); +const mockOpenSaveQueryModal = jest.mocked(modal.openSaveQueryModal); + +describe('useSaveAsItems', () => { + const organization = OrganizationFixture({ + features: ['ourlogs-alerts', 'ourlogs-dashboards', 'ourlogs-saved-queries'], + }); + const project = ProjectFixture({id: '1'}); + const queryClient = makeTestQueryClient(); + let saveQueryMock: jest.Mock; + ProjectsStore.loadInitialData([project]); + + function createWrapper() { + return function ({children}: {children?: React.ReactNode}) { + return ( + + + + {children} + + + + ); + }; + } + + beforeEach(() => { + jest.resetAllMocks(); + MockApiClient.clearMockResponses(); + queryClient.clear(); + + mockedUseLocation.mockReturnValue(LocationFixture()); + mockUseNavigate.mockReturnValue(jest.fn()); + mockUsePageFilters.mockReturnValue({ + isReady: true, + desyncedFilters: new Set(), + pinnedFilters: new Set(), + shouldPersist: true, + selection: PageFiltersFixture({ + projects: [1], + environments: ['production'], + datetime: { + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-01T01:00:00.000Z', + period: '1h', + utc: false, + }, + }), + }); + + saveQueryMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/explore/saved/`, + method: 'POST', + body: {id: 'new-query-id', name: 'Test Query'}, + }); + }); + + it('should open save query modal when save as query is clicked', () => { + const {result} = renderHook( + () => + useSaveAsItems({ + aggregate: 'count()', + groupBy: 'message.template', + interval: '5m', + mode: Mode.AGGREGATE, + search: new MutableSearch('message:"test error"'), + sortBys: [{field: 'timestamp', kind: 'desc'}], + }), + {wrapper: createWrapper()} + ); + + const saveAsItems = result.current; + const saveAsQuery = saveAsItems.find(item => item.key === 'save-query') as { + onAction: () => void; + }; + + saveAsQuery?.onAction?.(); + + expect(mockOpenSaveQueryModal).toHaveBeenCalledWith({ + organization, + saveQuery: expect.any(Function), + source: 'table', + traceItemDataset: 'logs', + }); + }); + + it('should call saveQuery with correct parameters when modal saves', async () => { + const {result} = renderHook( + () => + useSaveAsItems({ + aggregate: 'count()', + groupBy: 'message.template', + interval: '5m', + mode: Mode.AGGREGATE, + search: new MutableSearch('message:"test error"'), + sortBys: [{field: 'timestamp', kind: 'desc'}], + }), + {wrapper: createWrapper()} + ); + + const saveAsItems = result.current; + const saveAsQuery = saveAsItems.find(item => item.key === 'save-query') as { + onAction: () => void; + }; + + saveAsQuery?.onAction?.(); + + expect(mockOpenSaveQueryModal).toHaveBeenCalled(); + + const modalCall = mockOpenSaveQueryModal.mock.calls[0]; + if (!modalCall) { + throw new Error('No modal call found'); + } + const saveQueryFn = modalCall[0].saveQuery; + + await saveQueryFn('Test Query Title', true); + + await waitFor(() => { + expect(saveQueryMock).toHaveBeenCalledWith( + `/organizations/${organization.slug}/explore/saved/`, + expect.objectContaining({ + method: 'POST', + data: expect.objectContaining({ + name: 'Test Query Title', + projects: [1], + dataset: 'logs', + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-01T01:00:00.000Z', + range: '1h', + environment: ['production'], + interval: '5m', + query: [ + { + fields: ['timestamp', 'message', 'user.email'], + orderby: '-timestamp', + query: 'message:"test error"', + groupby: ['message.template'], + mode: Mode.AGGREGATE, + }, + ], + starred: true, + }), + }) + ); + }); + }); +}); diff --git a/static/app/views/explore/logs/useSaveAsItems.tsx b/static/app/views/explore/logs/useSaveAsItems.tsx new file mode 100644 index 00000000000000..37ad546626d823 --- /dev/null +++ b/static/app/views/explore/logs/useSaveAsItems.tsx @@ -0,0 +1,221 @@ +import {useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {openSaveQueryModal} from 'sentry/actionCreators/modal'; +import Feature from 'sentry/components/acl/feature'; +import {t} from 'sentry/locale'; +import type {NewQuery} from 'sentry/types/organization'; +import {defined} from 'sentry/utils'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import EventView from 'sentry/utils/discover/eventView'; +import type {Sort} from 'sentry/utils/discover/fields'; +import {parseFunction, prettifyParsedFunction} from 'sentry/utils/discover/fields'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import {useLocation} from 'sentry/utils/useLocation'; +import useOrganization from 'sentry/utils/useOrganization'; +import usePageFilters from 'sentry/utils/usePageFilters'; +import useProjects from 'sentry/utils/useProjects'; +import useRouter from 'sentry/utils/useRouter'; +import {Dataset} from 'sentry/views/alerts/rules/metric/types'; +import { + DashboardWidgetSource, + DEFAULT_WIDGET_NAME, + DisplayType, + WidgetType, +} from 'sentry/views/dashboards/types'; +import {handleAddQueryToDashboard} from 'sentry/views/discover/utils'; +import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import {formatSort} from 'sentry/views/explore/contexts/pageParamsContext/sortBys'; +import {useLogsSaveQuery} from 'sentry/views/explore/hooks/useSaveQuery'; +import {TraceItemDataset} from 'sentry/views/explore/types'; +import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; + +interface UseSaveAsItemsOptions { + aggregate: string; + groupBy: string | undefined; + interval: string; + mode: Mode; + search: MutableSearch; + sortBys: Sort[]; +} + +export function useSaveAsItems({ + aggregate, + groupBy, + interval, + mode, + search, + sortBys, +}: UseSaveAsItemsOptions) { + const location = useLocation(); + const router = useRouter(); + const organization = useOrganization(); + const {projects} = useProjects(); + const pageFilters = usePageFilters(); + const {saveQuery} = useLogsSaveQuery(); + + const project = + projects.length === 1 + ? projects[0] + : projects.find(p => p.id === `${pageFilters.selection.projects[0]}`); + + const aggregates = useMemo(() => [aggregate], [aggregate]); + + const saveAsQuery = useMemo(() => { + return { + key: 'save-query', + label: {t('A New Query')}, + textValue: t('A New Query'), + onAction: () => { + trackAnalytics('logs.save_query_modal', { + action: 'open', + save_type: 'save_new_query', + ui_source: 'table', + organization, + }); + openSaveQueryModal({ + organization, + saveQuery, + source: 'table', + traceItemDataset: TraceItemDataset.LOGS, + }); + }, + }; + }, [organization, saveQuery]); + + const saveAsAlert = useMemo(() => { + const alertsUrls = aggregates.map((yAxis: string, index: number) => { + const func = parseFunction(yAxis); + const label = func ? prettifyParsedFunction(func) : yAxis; + return { + key: `${yAxis}-${index}`, + label, + to: getAlertsUrl({ + project, + query: search.formatString(), + pageFilters: pageFilters.selection, + aggregate: yAxis, + organization, + dataset: Dataset.EVENTS_ANALYTICS_PLATFORM, + interval, + eventTypes: 'trace_item_log', + }), + onAction: () => { + trackAnalytics('logs.save_as', { + save_type: 'alert', + ui_source: 'searchbar', + organization, + }); + }, + }; + }); + + return { + key: 'create-alert', + label: t('An Alert for'), + textValue: t('An Alert for'), + children: alertsUrls ?? [], + disabled: !alertsUrls || alertsUrls.length === 0, + isSubmenu: true, + }; + }, [aggregates, interval, organization, pageFilters, project, search]); + + const saveAsDashboard = useMemo(() => { + const dashboardsUrls = aggregates.map((yAxis: string, index: number) => { + const func = parseFunction(yAxis); + const label = func ? prettifyParsedFunction(func) : yAxis; + + return { + key: String(index), + label, + onAction: () => { + trackAnalytics('logs.save_as', { + save_type: 'dashboard', + ui_source: 'searchbar', + organization, + }); + + const fields = + mode === Mode.SAMPLES + ? [] + : [...new Set([groupBy, yAxis, ...sortBys.map(sort => sort.field)])].filter( + defined + ); + + const discoverQuery: NewQuery = { + name: DEFAULT_WIDGET_NAME, + fields, + orderby: sortBys.map(formatSort), + query: search.formatString(), + version: 2, + dataset: DiscoverDatasets.OURLOGS, + yAxis: [yAxis], + }; + + const eventView = EventView.fromNewQueryWithPageFilters( + discoverQuery, + pageFilters.selection + ); + // the chart currently track the chart type internally so force bar type for now + eventView.display = DisplayType.BAR; + + handleAddQueryToDashboard({ + organization, + location, + eventView, + router, + yAxis: eventView.yAxis, + widgetType: WidgetType.LOGS, + source: DashboardWidgetSource.LOGS, + }); + }, + }; + }); + + return { + key: 'add-to-dashboard', + label: ( + {t('A Dashboard widget')}} + > + {t('A Dashboard widget')} + + ), + textValue: t('A Dashboard widget'), + children: dashboardsUrls, + disabled: !dashboardsUrls || dashboardsUrls.length === 0, + isSubmenu: true, + }; + }, [ + aggregates, + groupBy, + mode, + organization, + pageFilters, + search, + sortBys, + location, + router, + ]); + + return useMemo(() => { + const saveAs = []; + if (organization.features.includes('ourlogs-saved-queries')) { + saveAs.push(saveAsQuery); + } + if (organization.features.includes('ourlogs-alerts')) { + saveAs.push(saveAsAlert); + } + if (organization.features.includes('ourlogs-dashboards')) { + saveAs.push(saveAsDashboard); + } + return saveAs; + }, [organization, saveAsQuery, saveAsAlert, saveAsDashboard]); +} + +const DisabledText = styled('span')` + color: ${p => p.theme.disabled}; +`; diff --git a/static/app/views/explore/logs/utils.tsx b/static/app/views/explore/logs/utils.tsx index e0f2138518888c..2873ddcac5a980 100644 --- a/static/app/views/explore/logs/utils.tsx +++ b/static/app/views/explore/logs/utils.tsx @@ -1,8 +1,10 @@ import type {ReactNode} from 'react'; import * as Sentry from '@sentry/react'; +import * as qs from 'query-string'; import type {ApiResult} from 'sentry/api'; import {t} from 'sentry/locale'; +import type {PageFilters} from 'sentry/types/core'; import type {TagCollection} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; @@ -17,7 +19,18 @@ import { import parseLinkHeader from 'sentry/utils/parseLinkHeader'; import type {InfiniteData, InfiniteQueryObserverResult} from 'sentry/utils/queryClient'; import type {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import {prettifyAttributeName} from 'sentry/views/explore/components/traceItemAttributes/utils'; +import { + LOGS_AGGREGATE_FN_KEY, + LOGS_AGGREGATE_PARAM_KEY, + LOGS_FIELDS_KEY, + LOGS_GROUP_BY_KEY, + LOGS_QUERY_KEY, +} from 'sentry/views/explore/contexts/logs/logsPageParams'; +import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; +import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import {SavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries'; import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; import { LogAttributesHumanLabel, @@ -328,3 +341,104 @@ export function hasLogsOnReplays(organization: Organization): boolean { organization.features.includes('ourlogs-replay-ui') ); } + +export function getLogsUrl({ + organization, + selection, + query, + field, + groupBy, + id, + interval, + mode, + referrer, + sortBy, + title, + aggregateFn, + aggregateParam, +}: { + organization: Organization; + aggregateFn?: string; + aggregateParam?: string; + field?: string[]; + groupBy?: string[]; + id?: number; + interval?: string; + mode?: Mode; + query?: string; + referrer?: string; + selection?: PageFilters; + sortBy?: string; + title?: string; +}) { + const {start, end, period: statsPeriod, utc} = selection?.datetime ?? {}; + const {environments, projects} = selection ?? {}; + const queryParams = { + project: projects, + environment: environments, + statsPeriod, + start, + end, + [LOGS_QUERY_KEY]: query, + utc, + [LOGS_FIELDS_KEY]: field, + [LOGS_GROUP_BY_KEY]: groupBy, + id, + interval, + mode, + referrer, + [LOGS_SORT_BYS_KEY]: sortBy, + [LOGS_AGGREGATE_FN_KEY]: aggregateFn, + [LOGS_AGGREGATE_PARAM_KEY]: aggregateParam, + title, + }; + + return ( + makeLogsPathname({organization, path: '/'}) + + `?${qs.stringify(queryParams, {skipNull: true})}` + ); +} + +function makeLogsPathname({ + organization, + path, +}: { + organization: Organization; + path: string; +}) { + return normalizeUrl(`/organizations/${organization.slug}/explore/logs${path}`); +} + +export function getLogsUrlFromSavedQueryUrl( + savedQuery: SavedQuery, + organization: Organization +) { + const firstQuery = savedQuery.query[0]; + const visualize = firstQuery.visualize?.[0]?.yAxes?.[0]; + const aggregateFn = visualize ? visualize.split('(')[0] : undefined; + const aggregateParam = visualize ? visualize.split('(')[1]?.split(')')[0] : undefined; + + return getLogsUrl({ + organization, + field: firstQuery.fields, + groupBy: firstQuery.groupby, + sortBy: firstQuery.orderby, + title: savedQuery.name, + id: savedQuery.id, + interval: savedQuery.interval, + mode: firstQuery.mode, + query: firstQuery.query, + aggregateFn, + aggregateParam, + selection: { + datetime: { + end: savedQuery.end ?? null, + period: savedQuery.range ?? null, + start: savedQuery.start ?? null, + utc: null, + }, + environments: savedQuery.environment ? [...savedQuery.environment] : [], + projects: savedQuery.projects ? [...savedQuery.projects] : [], + }, + }); +} diff --git a/static/app/views/explore/multiQueryMode/content.tsx b/static/app/views/explore/multiQueryMode/content.tsx index 5d9b91f7cf1c34..1c9bfc22f9127f 100644 --- a/static/app/views/explore/multiQueryMode/content.tsx +++ b/static/app/views/explore/multiQueryMode/content.tsx @@ -153,6 +153,7 @@ function Content() { organization, saveQuery, source: 'toolbar', + traceItemDataset: TraceItemDataset.SPANS, }); }, }, diff --git a/static/app/views/explore/savedQueries/index.tsx b/static/app/views/explore/savedQueries/index.tsx index 98a5bdf4400751..f285c6ef1eb7e9 100644 --- a/static/app/views/explore/savedQueries/index.tsx +++ b/static/app/views/explore/savedQueries/index.tsx @@ -1,16 +1,44 @@ +import {useNavigate} from 'react-router-dom'; + +import {Button} from 'sentry/components/core/button'; import {ButtonBar} from 'sentry/components/core/button/buttonBar'; import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {DropdownMenu} from 'sentry/components/dropdownMenu'; import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton'; import * as Layout from 'sentry/components/layouts/thirds'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {IconAdd} from 'sentry/icons/iconAdd'; import {t} from 'sentry/locale'; import useOrganization from 'sentry/utils/useOrganization'; +import {getLogsUrl} from 'sentry/views/explore/logs/utils'; import {SavedQueriesLandingContent} from 'sentry/views/explore/savedQueries/savedQueriesLandingContent'; import {getExploreUrl} from 'sentry/views/explore/utils'; export default function SavedQueriesView() { const organization = useOrganization(); + const hasLogsFeature = + organization.features.includes('ourlogs-enabled') && + organization.features.includes('ourlogs-saved-queries'); + const navigate = useNavigate(); + + const items = [ + { + key: 'create-query-spans', + label: {t('Trace Query')}, + textValue: t('Create Traces Query'), + onAction: () => { + navigate(getExploreUrl({organization, visualize: []})); + }, + }, + { + key: 'create-query-logs', + label: {t('Logs Query')}, + textValue: t('Create Logs Query'), + onAction: () => { + navigate(getLogsUrl({organization})); + }, + }, + ]; return ( @@ -22,14 +50,37 @@ export default function SavedQueriesView() { - } - size="sm" - to={getExploreUrl({organization, visualize: []})} - > - {t('Create Query')} - + {hasLogsFeature ? ( + ( + + )} + /> + ) : ( + } + size="sm" + to={getExploreUrl({organization, visualize: []})} + > + {t('Create Query')} + + )} diff --git a/static/app/views/explore/savedQueries/savedQueriesTable.spec.tsx b/static/app/views/explore/savedQueries/savedQueriesTable.spec.tsx index d147c9c658684e..d35680cd32f075 100644 --- a/static/app/views/explore/savedQueries/savedQueriesTable.spec.tsx +++ b/static/app/views/explore/savedQueries/savedQueriesTable.spec.tsx @@ -178,6 +178,80 @@ describe('SavedQueriesTable', () => { ); }); + it('should link to a single query view for logs dataset', async () => { + getQueriesMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/explore/saved/`, + body: [ + { + id: 1, + name: 'Logs Query Name', + projects: [1], + environment: ['production'], + createdBy: { + name: 'Test User', + }, + query: [ + { + mode: 'samples', + fields: ['timestamp', 'message', 'user.email'], + groupby: ['message'], + query: + 'message:"System time zone does not match user preferences time zone"', + orderby: 'user.email', + }, + ], + range: '1h', + interval: '5m', + dataset: 'logs', + }, + ], + }); + render(, { + deprecatedRouterMocks: true, + }); + expect(await screen.findByText('Logs Query Name')).toHaveAttribute( + 'href', + '/organizations/org-slug/explore/logs/?environment=production&id=1&interval=5m&logsFields=timestamp&logsFields=message&logsFields=user.email&logsGroupBy=message&logsQuery=message%3A%22System%20time%20zone%20does%20not%20match%20user%20preferences%20time%20zone%22&logsSortBys=user.email&mode=samples&project=1&statsPeriod=1h&title=Logs%20Query%20Name' + ); + }); + + it('should link to a single query view for logs dataset with aggregate', async () => { + getQueriesMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/explore/saved/`, + body: [ + { + id: 1, + name: 'ABC', + projects: [1], + environment: ['production'], + createdBy: { + name: 'User1', + }, + query: [ + { + mode: 'samples', + fields: ['timestamp', 'tags[amount,number]'], + groupby: ['message'], + query: 'message:foo', + orderby: 'user.email', + visualize: [{yAxes: ['avg(tags[amount,number])']}], + }, + ], + range: '1h', + interval: '5m', + dataset: 'logs', + }, + ], + }); + render(, { + deprecatedRouterMocks: true, + }); + expect(await screen.findByText('ABC')).toHaveAttribute( + 'href', + '/organizations/org-slug/explore/logs/?environment=production&id=1&interval=5m&logsAggregate=avg&logsAggregateParam=tags%5Bamount%2Cnumber%5D&logsFields=timestamp&logsFields=tags%5Bamount%2Cnumber%5D&logsGroupBy=message&logsQuery=message%3Afoo&logsSortBys=user.email&mode=samples&project=1&statsPeriod=1h&title=ABC' + ); + }); + it('should display starred status', async () => { getQueriesMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/explore/saved/`, diff --git a/static/app/views/explore/savedQueries/savedQueriesTable.tsx b/static/app/views/explore/savedQueries/savedQueriesTable.tsx index df34c4fb1bcb65..4ba0e07463d4dd 100644 --- a/static/app/views/explore/savedQueries/savedQueriesTable.tsx +++ b/static/app/views/explore/savedQueries/savedQueriesTable.tsx @@ -22,13 +22,17 @@ import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {useDeleteQuery} from 'sentry/views/explore/hooks/useDeleteQuery'; import { + getSavedQueryDatasetLabel, + getSavedQueryTraceItemDataset, type SavedQuery, type SortOption, useGetSavedQueries, } from 'sentry/views/explore/hooks/useGetSavedQueries'; -import {useSaveQuery} from 'sentry/views/explore/hooks/useSaveQuery'; +import {useFromSavedQuery} from 'sentry/views/explore/hooks/useSaveQuery'; import {useStarQuery} from 'sentry/views/explore/hooks/useStarQuery'; +import {getLogsUrlFromSavedQueryUrl} from 'sentry/views/explore/logs/utils'; import {ExploreParams} from 'sentry/views/explore/savedQueries/exploreParams'; +import {TraceItemDataset} from 'sentry/views/explore/types'; import { confirmDeleteSavedQuery, getExploreUrlFromSavedQueryUrl, @@ -56,6 +60,9 @@ export function SavedQueriesTable({ const organization = useOrganization(); const location = useLocation(); const navigate = useNavigate(); + const hasLogsSavedQueriesEnabled = + organization.features.includes('ourlogs-enabled') && + organization.features.includes('ourlogs-saved-queries'); const cursor = decodeScalar(location.query[cursorKey]); const {data, isLoading, pageLinks, isFetched, isError} = useGetSavedQueries({ sortBy: ['starred', sort], @@ -67,7 +74,7 @@ export function SavedQueriesTable({ const filteredData = data?.filter(row => row.query?.length > 0) ?? []; const {deleteQuery} = useDeleteQuery(); const {starQuery} = useStarQuery(); - const {saveQueryFromSavedQuery, updateQueryFromSavedQuery} = useSaveQuery(); + const {saveQueryFromSavedQuery, updateQueryFromSavedQuery} = useFromSavedQuery(); const [starredIds, setStarredIds] = useState([]); @@ -79,17 +86,25 @@ export function SavedQueriesTable({ }, [isFetched, data]); const starQueryHandler = useCallback( - (id: number, starred: boolean) => { + (id: number, starred: boolean, dataset: TraceItemDataset) => { if (starred) { setStarredIds(prev => [...prev, id]); } else { setStarredIds(prev => prev.filter(starredId => starredId !== id)); } - trackAnalytics('trace_explorer.star_query', { - save_type: starred ? 'star_query' : 'unstar_query', - ui_source: 'table', - organization, - }); + if (dataset === TraceItemDataset.SPANS) { + trackAnalytics('trace_explorer.star_query', { + save_type: starred ? 'star_query' : 'unstar_query', + ui_source: 'table', + organization, + }); + } else if (dataset === TraceItemDataset.LOGS) { + trackAnalytics('logs.star_query', { + save_type: starred ? 'star_query' : 'unstar_query', + ui_source: 'table', + organization, + }); + } starQuery(id, starred).catch(() => { // If the starQuery call fails, we need to revert the starredIds state addErrorMessage(t('Unable to star query')); @@ -106,7 +121,10 @@ export function SavedQueriesTable({ const getHandleUpdateFromSavedQuery = useCallback( (savedQuery: SavedQuery) => { return (name: string) => { - return updateQueryFromSavedQuery({...savedQuery, name}); + return updateQueryFromSavedQuery({ + ...savedQuery, + name, + }); }; }, [updateQueryFromSavedQuery] @@ -129,14 +147,14 @@ export function SavedQueriesTable({ const debouncedOnClick = useMemo( () => debounce( - (id, starred) => { + (id, starred, dataset) => { if (starred) { addLoadingMessage(t('Unstarring query...')); - starQueryHandler(id, false); + starQueryHandler(id, false, dataset); addSuccessMessage(t('Query unstarred')); } else { addLoadingMessage(t('Starring query...')); - starQueryHandler(id, true); + starQueryHandler(id, true, dataset); addSuccessMessage(t('Query starred')); } }, @@ -154,6 +172,7 @@ export function SavedQueriesTable({ {title} {t('Name')} + {hasLogsSavedQueriesEnabled && ( + + {t('Type')} + + )} {t('Project')} @@ -193,16 +217,31 @@ export function SavedQueriesTable({ debouncedOnClick(query.id, query.starred)} + onClick={() => + debouncedOnClick( + query.id, + query.starred, + getSavedQueryTraceItemDataset(query.dataset) + ) + } /> {query.name} + {hasLogsSavedQueriesEnabled && ( + + {getSavedQueryDatasetLabel(query.dataset)} + + )} @@ -238,17 +277,35 @@ export function SavedQueriesTable({ key: 'rename', label: t('Rename'), onAction: () => { - trackAnalytics('trace_explorer.save_query_modal', { - action: 'open', - save_type: 'rename_query', - ui_source: 'table', - organization, - }); + if ( + getSavedQueryTraceItemDataset(query.dataset) === + TraceItemDataset.SPANS + ) { + trackAnalytics('trace_explorer.save_query_modal', { + action: 'open', + save_type: 'rename_query', + ui_source: 'table', + organization, + }); + } else if ( + getSavedQueryTraceItemDataset(query.dataset) === + TraceItemDataset.LOGS + ) { + trackAnalytics('logs.save_query_modal', { + action: 'open', + save_type: 'rename_query', + ui_source: 'table', + organization, + }); + } openSaveQueryModal({ organization, saveQuery: getHandleUpdateFromSavedQuery(query), name: query.name, source: 'table', + traceItemDataset: getSavedQueryTraceItemDataset( + query.dataset + ), }); }, }, @@ -304,15 +361,19 @@ const Container = styled('div')` container-type: inline-size; `; -const SavedEntityTableWithColumns = styled(SavedEntityTable)` +const SavedEntityTableWithColumns = styled(SavedEntityTable)<{hasLogsEnabled: boolean}>` grid-template-areas: 'star name project envs query created-by last-visited actions'; - grid-template-columns: - 40px 20% minmax(auto, 120px) minmax(auto, 120px) minmax(0, 1fr) - auto auto 48px; + grid-template-columns: ${p => + p.hasLogsEnabled + ? '40px 20% min-content minmax(auto, 120px) minmax(auto, 120px) minmax(0, 1fr) auto auto 48px' + : '40px 20% minmax(auto, 120px) minmax(auto, 120px) minmax(0, 1fr) auto auto 48px'}; @container (max-width: ${p => p.theme.breakpoints.md}) { grid-template-areas: 'star name project query created-by actions'; - grid-template-columns: 40px 20% minmax(auto, 120px) minmax(0, 1fr) auto 48px; + grid-template-columns: ${p => + p.hasLogsEnabled + ? '40px 20% min-content minmax(auto, 120px) minmax(0, 1fr) auto 48px' + : '40px 20% minmax(auto, 120px) minmax(0, 1fr) auto 48px'}; div[data-column='envs'], div[data-column='last-visited'], @@ -331,7 +392,8 @@ const SavedEntityTableWithColumns = styled(SavedEntityTable)` div[data-column='created'], div[data-column='stars'], div[data-column='created-by'], - div[data-column='project'] { + div[data-column='project'], + div[data-column='dataset'] { display: none; } } diff --git a/static/app/views/explore/savedQueryEditMenu.tsx b/static/app/views/explore/savedQueryEditMenu.tsx index 919e884f7df1d8..d64c4cf1e2c0f7 100644 --- a/static/app/views/explore/savedQueryEditMenu.tsx +++ b/static/app/views/explore/savedQueryEditMenu.tsx @@ -9,7 +9,12 @@ import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {getIdFromLocation} from 'sentry/views/explore/contexts/pageParamsContext/id'; import {useDeleteQuery} from 'sentry/views/explore/hooks/useDeleteQuery'; -import {useGetSavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries'; +import { + getSavedQueryTraceItemDataset, + useGetSavedQuery, +} from 'sentry/views/explore/hooks/useGetSavedQueries'; +import {getLogsUrl} from 'sentry/views/explore/logs/utils'; +import {TraceItemDataset} from 'sentry/views/explore/types'; import {confirmDeleteSavedQuery} from 'sentry/views/explore/utils'; export function SavedQueryEditMenu() { @@ -35,20 +40,38 @@ export function SavedQueryEditMenu() { confirmDeleteSavedQuery({ handleDelete: async () => { await deleteQuery(savedQuery.id); - if (location.pathname.endsWith('compare/')) { - navigate( - normalizeUrl( - `/organizations/${organization.slug}/explore/traces/compare/` - ) - ); + if ( + getSavedQueryTraceItemDataset(savedQuery.dataset) === + TraceItemDataset.SPANS + ) { + if (location.pathname.endsWith('compare/')) { + navigate( + normalizeUrl( + `/organizations/${organization.slug}/explore/traces/compare/` + ) + ); + } else { + navigate( + normalizeUrl(`/organizations/${organization.slug}/explore/traces/`) + ); + } + trackAnalytics('trace_explorer.delete_query', { + organization, + }); + } else if ( + getSavedQueryTraceItemDataset(savedQuery.dataset) === + TraceItemDataset.LOGS + ) { + trackAnalytics('logs.delete_query', { + organization, + }); } else { navigate( - normalizeUrl(`/organizations/${organization.slug}/explore/traces/`) + getLogsUrl({ + organization, + }) ); } - trackAnalytics('trace_explorer.delete_query', { - organization, - }); }, savedQuery, }); diff --git a/static/app/views/explore/starSavedQueryButton.tsx b/static/app/views/explore/starSavedQueryButton.tsx index a63813d722f7b0..21b6730cfdaa3c 100644 --- a/static/app/views/explore/starSavedQueryButton.tsx +++ b/static/app/views/explore/starSavedQueryButton.tsx @@ -10,8 +10,12 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {getIdFromLocation} from 'sentry/views/explore/contexts/pageParamsContext/id'; -import {useGetSavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries'; +import { + getSavedQueryTraceItemDataset, + useGetSavedQuery, +} from 'sentry/views/explore/hooks/useGetSavedQueries'; import {useStarQuery} from 'sentry/views/explore/hooks/useStarQuery'; +import {TraceItemDataset} from 'sentry/views/explore/types'; export function StarSavedQueryButton() { const organization = useOrganization(); @@ -34,11 +38,23 @@ export function StarSavedQueryButton() { return; } try { - trackAnalytics('trace_explorer.star_query', { - save_type: starred ? 'star_query' : 'unstar_query', - ui_source: 'explorer', - organization, - }); + if (data?.dataset) { + if (getSavedQueryTraceItemDataset(data?.dataset) === TraceItemDataset.SPANS) { + trackAnalytics('trace_explorer.star_query', { + save_type: starred ? 'star_query' : 'unstar_query', + ui_source: 'explorer', + organization, + }); + } else if ( + getSavedQueryTraceItemDataset(data?.dataset) === TraceItemDataset.LOGS + ) { + trackAnalytics('logs.star_query', { + save_type: starred ? 'star_query' : 'unstar_query', + ui_source: 'explorer', + organization, + }); + } + } starQuery(parseInt(id, 10), starred); setIsStarred(starred); } catch (error) { @@ -50,7 +66,7 @@ export function StarSavedQueryButton() { 1000, {leading: true} ); - }, [starQuery, organization]); + }, [starQuery, organization, data?.dataset]); if (isLoading || !locationId) { return null; diff --git a/static/app/views/explore/toolbar/toolbarSaveAs.tsx b/static/app/views/explore/toolbar/toolbarSaveAs.tsx index 3c6e99af16eb58..b7456b540def24 100644 --- a/static/app/views/explore/toolbar/toolbarSaveAs.tsx +++ b/static/app/views/explore/toolbar/toolbarSaveAs.tsx @@ -38,14 +38,15 @@ import { import {useAddToDashboard} from 'sentry/views/explore/hooks/useAddToDashboard'; import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval'; import {useGetSavedQuery} from 'sentry/views/explore/hooks/useGetSavedQueries'; -import {useSaveQuery} from 'sentry/views/explore/hooks/useSaveQuery'; +import {useSpansSaveQuery} from 'sentry/views/explore/hooks/useSaveQuery'; import {generateExploreCompareRoute} from 'sentry/views/explore/multiQueryMode/locationUtils'; import {useQueryParamsMode} from 'sentry/views/explore/queryParams/context'; +import {TraceItemDataset} from 'sentry/views/explore/types'; import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; export function ToolbarSaveAs() { const {addToDashboard} = useAddToDashboard(); - const {updateQuery, saveQuery} = useSaveQuery(); + const {updateQuery, saveQuery} = useSpansSaveQuery(); const location = useLocation(); const organization = useOrganization(); @@ -141,6 +142,7 @@ export function ToolbarSaveAs() { organization, saveQuery, source: 'toolbar', + traceItemDataset: TraceItemDataset.SPANS, }); }, });