Browse Source

feat(ourlogs): Add sorting to the logs table (#85676)

### Summary
This adds sorting with a custom provider. Since sorts involve fields,
this adds fields to the log context as well. Most of the changes to the
table structure are temporary as when custom fields are working we'll
have to switch to GridEditable anyway.

#### Other
- Tests will be added for the table when the functionality is done
(column editing and the accordion details view).
- Moved the field keys out of insights for the moment since we are only
doing generic table searching of field (coming up with the editable
columns changes for this table). We can figure out which types belong in
the insights domains once we start using logs in insights.
Kev 2 weeks ago
parent
commit
76d358f074

+ 23 - 0
static/app/views/explore/contexts/logs/fields.tsx

@@ -0,0 +1,23 @@
+import type {Location} from 'history';
+
+import {decodeList} from 'sentry/utils/queryString';
+import {type OurLogFieldKey, OurLogKnownFieldKey} from 'sentry/views/explore/logs/types';
+
+function defaultLogFields(): OurLogKnownFieldKey[] {
+  return [
+    OurLogKnownFieldKey.SEVERITY_TEXT,
+    OurLogKnownFieldKey.SEVERITY_NUMBER,
+    OurLogKnownFieldKey.BODY,
+    OurLogKnownFieldKey.TIMESTAMP,
+  ];
+}
+
+export function getLogFieldsFromLocation(location: Location): OurLogFieldKey[] {
+  const fields = decodeList(location.query.field) as OurLogFieldKey[];
+
+  if (fields.length) {
+    return fields;
+  }
+
+  return defaultLogFields();
+}

+ 56 - 2
static/app/views/explore/contexts/logs/logsPageParams.tsx

@@ -2,16 +2,24 @@ import {useCallback} from 'react';
 import type {Location} from 'history';
 
 import {defined} from 'sentry/utils';
+import type {Sort} from 'sentry/utils/discover/fields';
 import {createDefinedContext} from 'sentry/utils/performance/contexts/utils';
 import {decodeScalar} from 'sentry/utils/queryString';
 import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import {useLocation} from 'sentry/utils/useLocation';
 import {useNavigate} from 'sentry/utils/useNavigate';
+import {getLogFieldsFromLocation} from 'sentry/views/explore/contexts/logs/fields';
+import {
+  getLogSortBysFromLocation,
+  updateLocationWithLogSortBys,
+} from 'sentry/views/explore/contexts/logs/sortBys';
 
 const LOGS_QUERY_KEY = 'logsQuery'; // Logs may exist on other pages.
-
+const LOGS_CURSOR_KEY = 'logsCursor';
 interface LogsPageParams {
+  readonly fields: string[];
   readonly search: MutableSearch;
+  readonly sortBys: Sort[];
 }
 
 type LogPageParamsUpdate = Partial<LogsPageParams>;
@@ -25,9 +33,11 @@ export function LogsPageParamsProvider({children}: {children: React.ReactNode})
   const location = useLocation();
   const logsQuery = decodeLogsQuery(location);
   const search = new MutableSearch(logsQuery);
+  const fields = getLogFieldsFromLocation(location);
+  const sortBys = getLogSortBysFromLocation(location, fields);
 
   return (
-    <LogsPageParamsContext.Provider value={{search}}>
+    <LogsPageParamsContext.Provider value={{fields, search, sortBys}}>
       {children}
     </LogsPageParamsContext.Provider>
   );
@@ -46,6 +56,7 @@ const decodeLogsQuery = (location: Location): string => {
 function setLogsPageParams(location: Location, pageParams: LogPageParamsUpdate) {
   const target: Location = {...location, query: {...location.query}};
   updateNullableLocation(target, LOGS_QUERY_KEY, pageParams.search?.formatString());
+  updateLocationWithLogSortBys(target, pageParams.sortBys, LOGS_CURSOR_KEY);
   return target;
 }
 
@@ -97,3 +108,46 @@ export function useSetLogsQuery() {
     [setPageParams]
   );
 }
+
+export function useLogsSortBys() {
+  const {sortBys} = useLogsPageParams();
+  return sortBys;
+}
+
+export function useLogsFields() {
+  const {fields} = useLogsPageParams();
+  return fields;
+}
+
+export function useSetLogsSortBys() {
+  const setPageParams = useSetLogsPageParams();
+  const currentPageSortBys = useLogsSortBys();
+
+  return useCallback(
+    (desiredSortBys: ToggleableSortBy[]) => {
+      const targetSortBys: Sort[] = desiredSortBys.map(desiredSortBy => {
+        const currentSortBy = currentPageSortBys.find(
+          s => s.field === desiredSortBy.field
+        );
+        const reverseDirection = currentSortBy?.kind === 'asc' ? 'desc' : 'asc';
+        return {
+          field: desiredSortBy.field,
+          kind:
+            desiredSortBy.kind ??
+            reverseDirection ??
+            desiredSortBy.defaultDirection ??
+            'desc',
+        };
+      });
+
+      setPageParams({sortBys: targetSortBys});
+    },
+    [setPageParams, currentPageSortBys]
+  );
+}
+
+interface ToggleableSortBy {
+  field: string;
+  defaultDirection?: 'asc' | 'desc'; // Defaults to descending if not provided.
+  kind?: 'asc' | 'desc';
+}

+ 46 - 0
static/app/views/explore/contexts/logs/sortBys.tsx

@@ -0,0 +1,46 @@
+import type {Location} from 'history';
+
+import {defined} from 'sentry/utils';
+import type {Sort} from 'sentry/utils/discover/fields';
+import {decodeSorts} from 'sentry/utils/queryString';
+
+const LOGS_SORT_BYS_KEY = 'logsSortBys';
+function defaultLogSortBys(fields: string[]): Sort[] {
+  if (fields.includes('timestamp')) {
+    return [
+      {
+        field: 'timestamp',
+        kind: 'desc' as const,
+      },
+    ];
+  }
+
+  return [];
+}
+
+export function getLogSortBysFromLocation(location: Location, fields: string[]): Sort[] {
+  const sortBys = decodeSorts(location.query[LOGS_SORT_BYS_KEY]);
+
+  if (sortBys.length > 0) {
+    return sortBys;
+  }
+
+  return defaultLogSortBys(fields);
+}
+
+export function updateLocationWithLogSortBys(
+  location: Location,
+  sortBys: Sort[] | null | undefined,
+  cursorUrlParam: string
+) {
+  if (defined(sortBys)) {
+    location.query[LOGS_SORT_BYS_KEY] = sortBys.map(sortBy =>
+      sortBy.kind === 'desc' ? `-${sortBy.field}` : sortBy.field
+    );
+
+    // make sure to clear the cursor every time the query is updated
+    delete location.query[cursorUrlParam];
+  } else if (sortBys === null) {
+    delete location.query[LOGS_SORT_BYS_KEY];
+  }
+}

+ 1 - 1
static/app/views/explore/logs/logsTab.tsx

@@ -55,7 +55,7 @@ export function LogsTabContent({
         </FilterBarContainer>
       </Layout.Main>
       <LogsTableContainer fullWidth>
-        <LogsTable search={logsSearch} />
+        <LogsTable />
       </LogsTableContainer>
     </Layout.Body>
   );

+ 62 - 49
static/app/views/explore/logs/logsTable.tsx

@@ -5,18 +5,26 @@ import EmptyStateWarning, {EmptyStreamWrapper} from 'sentry/components/emptyStat
 import ExternalLink from 'sentry/components/links/externalLink';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {LOGS_PROPS_DOCS_URL} from 'sentry/constants';
-import {IconWarning} from 'sentry/icons';
+import {IconArrow, IconWarning} from 'sentry/icons';
 import {IconChevron} from 'sentry/icons/iconChevron';
 import {t, tct} from 'sentry/locale';
-import type {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {defined} from 'sentry/utils';
+import {
+  useLogsSearch,
+  useLogsSortBys,
+  useSetLogsSortBys,
+} from 'sentry/views/explore/contexts/logs/logsPageParams';
 import {
   bodyRenderer,
   severityCircleRenderer,
   severityTextRenderer,
   TimestampRenderer,
 } from 'sentry/views/explore/logs/fieldRenderers';
-import {useOurlogs} from 'sentry/views/insights/common/queries/useDiscover';
-import type {OurlogsFields} from 'sentry/views/insights/types';
+import {
+  OurLogKnownFieldKey,
+  type OurLogsResponseItem,
+} from 'sentry/views/explore/logs/types';
+import {useExploreLogsTable} from 'sentry/views/explore/logs/useLogsQuery';
 import {EmptyStateText} from 'sentry/views/traces/styles';
 
 import {
@@ -27,56 +35,61 @@ import {
   DetailsValue,
   DetailsWrapper,
   getLogColors,
+  HeaderCell,
   LogPanelContent,
   StyledChevronButton,
   StyledPanel,
-  StyledPanelHeader,
   StyledPanelItem,
 } from './styles';
 import {getLogBodySearchTerms, getLogSeverityLevel} from './utils';
 
-export type LogsTableProps = {
-  search: MutableSearch;
-};
-
 type LogsRowProps = {
-  dataRow: OurlogsFields;
+  dataRow: OurLogsResponseItem;
   highlightTerms: string[];
 };
 
-const LOG_FIELDS: Array<keyof OurlogsFields> = [
-  'log.severity_text',
-  'log.severity_number',
-  'log.body',
-  'timestamp',
-];
-
-export function LogsTable(props: LogsTableProps) {
-  const {data, isError, isPending} = useOurlogs(
-    {
-      limit: 100,
-      sorts: [],
-      fields: LOG_FIELDS,
-      search: props.search,
-    },
-    'api.logs-tab.view'
-  );
+export function LogsTable() {
+  const search = useLogsSearch();
+  const {data, isError, isPending} = useExploreLogsTable({
+    limit: 100,
+    search,
+  });
 
   const isEmpty = !isPending && !isError && (data?.length ?? 0) === 0;
-  const highlightTerms = getLogBodySearchTerms(props.search);
+  const highlightTerms = getLogBodySearchTerms(search);
+  const sortBys = useLogsSortBys();
+  const setSortBys = useSetLogsSortBys();
+
+  const headers: Array<{align: 'left' | 'right'; field: string; label: string}> = [
+    {field: 'log.severity_number', label: t('Severity'), align: 'left'},
+    {field: 'log.body', label: t('Message'), align: 'left'},
+    {field: 'timestamp', label: t('Timestamp'), align: 'right'},
+  ];
 
   return (
     <StyledPanel>
       <LogPanelContent>
-        <StyledPanelHeader align="left" lightText>
-          {t('Severity')}
-        </StyledPanelHeader>
-        <StyledPanelHeader align="left" lightText>
-          {t('Message')}
-        </StyledPanelHeader>
-        <StyledPanelHeader align="right" lightText>
-          {t('Timestamp')}
-        </StyledPanelHeader>
+        {headers.map((header, index) => {
+          const direction = sortBys.find(s => s.field === header.field)?.kind;
+          return (
+            <HeaderCell
+              key={index}
+              align={header.align}
+              lightText
+              onClick={() => setSortBys([{field: header.field}])}
+            >
+              {header.label}
+              {defined(direction) && (
+                <IconArrow
+                  size="xs"
+                  direction={
+                    direction === 'desc' ? 'down' : direction === 'asc' ? 'up' : undefined
+                  }
+                />
+              )}
+            </HeaderCell>
+          );
+        })}
         {isPending && (
           <StyledPanelItem span={3} overflow>
             <LoadingIndicator />
@@ -120,8 +133,8 @@ function LogsRow({dataRow, highlightTerms}: LogsRowProps) {
   const onClickExpand = useCallback(() => setExpanded(e => !e), [setExpanded]);
   const theme = useTheme();
   const level = getLogSeverityLevel(
-    dataRow['log.severity_number'],
-    dataRow['log.severity_text']
+    dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER],
+    dataRow[OurLogKnownFieldKey.SEVERITY_TEXT]
   );
   const logColors = getLogColors(level, theme);
 
@@ -136,18 +149,18 @@ function LogsRow({dataRow, highlightTerms}: LogsRowProps) {
           borderless
         />
         {severityCircleRenderer(
-          dataRow['log.severity_number'],
-          dataRow['log.severity_text'],
+          dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER],
+          dataRow[OurLogKnownFieldKey.SEVERITY_TEXT],
           logColors
         )}
         {severityTextRenderer(
-          dataRow['log.severity_number'],
-          dataRow['log.severity_text'],
+          dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER],
+          dataRow[OurLogKnownFieldKey.SEVERITY_TEXT],
           logColors
         )}
       </StyledPanelItem>
       <StyledPanelItem overflow>
-        {bodyRenderer(dataRow['log.body'], highlightTerms)}
+        {bodyRenderer(dataRow[OurLogKnownFieldKey.BODY], highlightTerms)}
       </StyledPanelItem>
       <StyledPanelItem align="right">
         <TimestampRenderer timestamp={dataRow.timestamp} />
@@ -161,12 +174,12 @@ function LogDetails({
   dataRow,
   highlightTerms,
 }: {
-  dataRow: OurlogsFields;
+  dataRow: OurLogsResponseItem;
   highlightTerms: string[];
 }) {
   const level = getLogSeverityLevel(
-    dataRow['log.severity_number'],
-    dataRow['log.severity_text']
+    dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER],
+    dataRow[OurLogKnownFieldKey.SEVERITY_TEXT]
   );
   const theme = useTheme();
   const logColors = getLogColors(level, theme);
@@ -183,8 +196,8 @@ function LogDetails({
           <DetailsLabel>Severity</DetailsLabel>
           <DetailsValue>
             {severityTextRenderer(
-              dataRow['log.severity_number'],
-              dataRow['log.severity_text'],
+              dataRow[OurLogKnownFieldKey.SEVERITY_NUMBER],
+              dataRow[OurLogKnownFieldKey.SEVERITY_TEXT],
               logColors,
               true
             )}

+ 2 - 1
static/app/views/explore/logs/styles.tsx

@@ -14,9 +14,10 @@ export const StyledPanel = styled(Panel)`
   margin-bottom: 0px;
 `;
 
-export const StyledPanelHeader = styled(PanelHeader)<{align: 'left' | 'right'}>`
+export const HeaderCell = styled(PanelHeader)<{align: 'left' | 'right'}>`
   white-space: nowrap;
   justify-content: ${p => (p.align === 'left' ? 'flex-start' : 'flex-end')};
+  cursor: pointer;
 `;
 
 export const StyledPanelItem = styled(PanelItem)<{

+ 28 - 0
static/app/views/explore/logs/types.tsx

@@ -0,0 +1,28 @@
+type OurLogCustomFieldKey = string; // We could brand this for nominal types.
+
+export enum OurLogKnownFieldKey {
+  BODY = 'log.body',
+  SEVERITY_NUMBER = 'log.severity_number',
+  SEVERITY_TEXT = 'log.severity_text',
+  ORGANIZATION_ID = 'sentry.organization_id',
+  PROJECT_ID = 'sentry.project_id',
+  SPAN_ID = 'sentry.span_id',
+  TIMESTAMP = 'timestamp',
+}
+
+export type OurLogFieldKey = OurLogCustomFieldKey | OurLogKnownFieldKey;
+
+export type OurLogsKnownFieldResponseMap = {
+  [OurLogKnownFieldKey.BODY]: string;
+  [OurLogKnownFieldKey.SEVERITY_NUMBER]: number;
+  [OurLogKnownFieldKey.SEVERITY_TEXT]: string;
+  [OurLogKnownFieldKey.ORGANIZATION_ID]: number;
+  [OurLogKnownFieldKey.PROJECT_ID]: number;
+  [OurLogKnownFieldKey.SPAN_ID]: string;
+  [OurLogKnownFieldKey.TIMESTAMP]: string;
+};
+
+type OurLogsCustomFieldResponseMap = Record<OurLogCustomFieldKey, string | number>;
+
+export type OurLogsResponseItem = OurLogsKnownFieldResponseMap &
+  OurLogsCustomFieldResponseMap;

+ 30 - 0
static/app/views/explore/logs/useLogsQuery.tsx

@@ -0,0 +1,30 @@
+import type EventView from 'sentry/utils/discover/eventView';
+import {
+  useLogsFields,
+  useLogsSearch,
+  useLogsSortBys,
+} from 'sentry/views/explore/contexts/logs/logsPageParams';
+import {useOurlogs} from 'sentry/views/insights/common/queries/useDiscover';
+
+export interface SpansTableResult {
+  eventView: EventView;
+  result: ReturnType<typeof useOurlogs>;
+}
+
+export function useExploreLogsTable(options: Parameters<typeof useOurlogs>[0]) {
+  const search = useLogsSearch();
+  const fields = useLogsFields();
+  const sortBys = useLogsSortBys();
+
+  const {data, isError, isPending} = useOurlogs(
+    {
+      ...options,
+      sorts: sortBys,
+      fields,
+      search,
+    },
+    'api.logs-tab.view'
+  );
+
+  return {data, isError, isPending};
+}

+ 4 - 4
static/app/views/insights/common/queries/useDiscover.ts

@@ -4,13 +4,13 @@ import type {Sort} from 'sentry/utils/discover/fields';
 import {DiscoverDatasets} from 'sentry/utils/discover/types';
 import type {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import usePageFilters from 'sentry/utils/usePageFilters';
+import type {OurLogFieldKey, OurLogsResponseItem} from 'sentry/views/explore/logs/types';
 import {useWrappedDiscoverQuery} from 'sentry/views/insights/common/queries/useSpansQuery';
 import type {
   EAPSpanProperty,
   EAPSpanResponse,
   MetricsProperty,
   MetricsResponse,
-  OurlogsFields,
   SpanIndexedField,
   SpanIndexedProperty,
   SpanIndexedResponse,
@@ -41,16 +41,16 @@ export const useSpansIndexed = <Fields extends SpanIndexedProperty[]>(
   );
 };
 
-export const useOurlogs = <Fields extends Array<keyof OurlogsFields>>(
+export const useOurlogs = <Fields extends OurLogFieldKey[]>(
   options: UseMetricsOptions<Fields> = {},
   referrer: string
 ) => {
-  const {data, ...rest} = useDiscover<Fields, OurlogsFields>(
+  const {data, ...rest} = useDiscover<Fields, OurLogsResponseItem>(
     options,
     DiscoverDatasets.OURLOGS,
     referrer
   );
-  const castData = data as OurlogsFields[];
+  const castData = data as OurLogsResponseItem[];
   return {...rest, data: castData};
 };
 

+ 0 - 10
static/app/views/insights/types.tsx

@@ -440,13 +440,3 @@ export const subregionCodeToName = {
 };
 
 export type SubregionCode = keyof typeof subregionCodeToName;
-
-export type OurlogsFields = {
-  'log.body': string;
-  'log.severity_number': number;
-  'log.severity_text': string;
-  'sentry.organization_id': number;
-  'sentry.project_id': number;
-  'sentry.span_id': string;
-  timestamp: string;
-};