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"
+ aria-label={t('Save as')}
+ onClick={e => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ triggerProps.onClick?.(e);
+ }}
+ >
+ {t('Create Query')}
+
+ )}
+ />
+ ) : (
+ }
+ 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,
});
},
});