Browse Source

ref(trace-explorer): Split traces hooks into separate files (#74036)

Refactoring the trace explorer into smaller pieces. Starting with
splitting the hooks out into a separate folder.
Tony Xiao 8 months ago
parent
commit
24148cba43

+ 7 - 248
static/app/views/traces/content.tsx

@@ -1,4 +1,4 @@
-import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
+import {Fragment, useCallback, useMemo, useState} from 'react';
 import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import debounce from 'lodash/debounce';
@@ -15,7 +15,6 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
 import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
 import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
-import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
 import Panel from 'sentry/components/panels/panel';
 import PanelHeader from 'sentry/components/panels/panelHeader';
@@ -27,7 +26,6 @@ import {IconClose} from 'sentry/icons/iconClose';
 import {IconWarning} from 'sentry/icons/iconWarning';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import type {PageFilters} from 'sentry/types/core';
 import type {MetricAggregation, MRI} from 'sentry/types/metrics';
 import type {Organization} from 'sentry/types/organization';
 import {defined} from 'sentry/utils';
@@ -35,17 +33,20 @@ import {trackAnalytics} from 'sentry/utils/analytics';
 import {browserHistory} from 'sentry/utils/browserHistory';
 import {getUtcDateString} from 'sentry/utils/dates';
 import {getFormattedMQL} from 'sentry/utils/metrics';
-import {useApiQuery} from 'sentry/utils/queryClient';
-import {decodeInteger, decodeList, decodeScalar} from 'sentry/utils/queryString';
+import {decodeInteger, decodeList} from 'sentry/utils/queryString';
 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 * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout';
 
+import {usePageParams} from './hooks/usePageParams';
+import type {TraceResult} from './hooks/useTraces';
+import {useTraces} from './hooks/useTraces';
+import type {SpanResult} from './hooks/useTraceSpans';
+import {useTraceSpans} from './hooks/useTraceSpans';
 import {type Field, FIELDS, SORTS} from './data';
 import {
-  BREAKDOWN_SLICES,
   Description,
   ProjectBadgeWrapper,
   ProjectsRenderer,
@@ -72,27 +73,6 @@ const SPAN_PROPS_DOCS_URL =
   'https://docs.sentry.io/concepts/search/searchable-properties/spans/';
 const ONE_MINUTE = 60 * 1000; // in milliseconds
 
-function usePageParams(location) {
-  const queries = useMemo(() => {
-    return decodeList(location.query.query);
-  }, [location.query.query]);
-
-  const metricsMax = decodeScalar(location.query.metricsMax);
-  const metricsMin = decodeScalar(location.query.metricsMin);
-  const metricsOp = decodeScalar(location.query.metricsOp);
-  const metricsQuery = decodeScalar(location.query.metricsQuery);
-  const mri = decodeScalar(location.query.mri);
-
-  return {
-    queries,
-    metricsMax,
-    metricsMin,
-    metricsOp,
-    metricsQuery,
-    mri,
-  };
-}
-
 export function Content() {
   const location = useLocation();
 
@@ -592,227 +572,6 @@ function SpanRow({
   );
 }
 
-export type SpanResult<F extends string> = Record<F, any>;
-
-export interface TraceResult {
-  breakdowns: TraceBreakdownResult[];
-  duration: number;
-  end: number;
-  matchingSpans: number;
-  name: string | null;
-  numErrors: number;
-  numOccurrences: number;
-  numSpans: number;
-  project: string | null;
-  slices: number;
-  start: number;
-  trace: string;
-}
-
-interface TraceBreakdownBase {
-  duration: number; // Contains the accurate duration for display. Start and end may be quantized.
-  end: number;
-  opCategory: string | null;
-  sdkName: string | null;
-  sliceEnd: number;
-  sliceStart: number;
-  sliceWidth: number;
-  start: number;
-}
-
-type TraceBreakdownProject = TraceBreakdownBase & {
-  kind: 'project';
-  project: string;
-};
-
-type TraceBreakdownMissing = TraceBreakdownBase & {
-  kind: 'missing';
-  project: null;
-};
-
-export type TraceBreakdownResult = TraceBreakdownProject | TraceBreakdownMissing;
-
-interface TraceResults {
-  data: TraceResult[];
-  meta: any;
-}
-
-interface UseTracesOptions {
-  datetime?: PageFilters['datetime'];
-  enabled?: boolean;
-  limit?: number;
-  metricsMax?: string;
-  metricsMin?: string;
-  metricsOp?: string;
-  metricsQuery?: string;
-  mri?: string;
-  query?: string | string[];
-  sort?: '-timestamp';
-}
-
-function useTraces({
-  datetime,
-  enabled,
-  limit,
-  mri,
-  metricsMax,
-  metricsMin,
-  metricsOp,
-  metricsQuery,
-  query,
-  sort,
-}: UseTracesOptions) {
-  const organization = useOrganization();
-  const {projects} = useProjects();
-  const {selection} = usePageFilters();
-
-  const path = `/organizations/${organization.slug}/traces/`;
-
-  const endpointOptions = {
-    query: {
-      project: selection.projects,
-      environment: selection.environments,
-      ...(datetime ?? normalizeDateTimeParams(selection.datetime)),
-      query,
-      sort,
-      per_page: limit,
-      breakdownSlices: BREAKDOWN_SLICES,
-      mri,
-      metricsMax,
-      metricsMin,
-      metricsOp,
-      metricsQuery,
-    },
-  };
-  const serializedEndpointOptions = JSON.stringify(endpointOptions);
-
-  let queries: string[] = [];
-  if (Array.isArray(query)) {
-    queries = query;
-  } else if (query !== undefined) {
-    queries = [query];
-  }
-
-  useEffect(() => {
-    trackAnalytics('trace_explorer.search_request', {
-      organization,
-      queries,
-    });
-    // `queries` is already included as a dep in serializedEndpointOptions
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [serializedEndpointOptions, organization]);
-
-  const result = useApiQuery<TraceResults>([path, endpointOptions], {
-    staleTime: 0,
-    refetchOnWindowFocus: false,
-    retry: false,
-    enabled,
-  });
-
-  useEffect(() => {
-    if (result.status === 'success') {
-      const project_slugs = [...new Set(result.data.data.map(trace => trace.project))];
-      const project_platforms = projects
-        .filter(p => project_slugs.includes(p.slug))
-        .map(p => p.platform ?? '');
-
-      trackAnalytics('trace_explorer.search_success', {
-        organization,
-        queries,
-        has_data: result.data.data.length > 0,
-        num_traces: result.data.data.length,
-        num_missing_trace_root: result.data.data.filter(trace => trace.name === null)
-          .length,
-        project_platforms,
-      });
-    } else if (result.status === 'error') {
-      const response = result.error.responseJSON;
-      const error =
-        typeof response?.detail === 'string'
-          ? response?.detail
-          : response?.detail?.message;
-      trackAnalytics('trace_explorer.search_failure', {
-        organization,
-        queries,
-        error: error ?? '',
-      });
-    }
-    // result.status is tied to result.data. No need to explicitly
-    // include result.data as an additional dep.
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [serializedEndpointOptions, result.status, organization]);
-
-  return result;
-}
-
-interface SpanResults<F extends string> {
-  data: SpanResult<F>[];
-  meta: any;
-}
-
-interface UseTraceSpansOptions<F extends string> {
-  fields: F[];
-  trace: TraceResult;
-  datetime?: PageFilters['datetime'];
-  enabled?: boolean;
-  limit?: number;
-  metricsMax?: string;
-  metricsMin?: string;
-  metricsOp?: string;
-  metricsQuery?: string;
-  mri?: string;
-  query?: string | string[];
-  sort?: string[];
-}
-
-function useTraceSpans<F extends string>({
-  fields,
-  trace,
-  datetime,
-  enabled,
-  limit,
-  mri,
-  metricsMax,
-  metricsMin,
-  metricsOp,
-  metricsQuery,
-  query,
-  sort,
-}: UseTraceSpansOptions<F>) {
-  const organization = useOrganization();
-  const {selection} = usePageFilters();
-
-  const path = `/organizations/${organization.slug}/trace/${trace.trace}/spans/`;
-
-  const endpointOptions = {
-    query: {
-      project: selection.projects,
-      environment: selection.environments,
-      ...(datetime ?? normalizeDateTimeParams(selection.datetime)),
-      field: fields,
-      query,
-      sort,
-      per_page: limit,
-      breakdownSlices: BREAKDOWN_SLICES,
-      maxSpansPerTrace: 10,
-      mri,
-      metricsMax,
-      metricsMin,
-      metricsOp,
-      metricsQuery,
-    },
-  };
-
-  const result = useApiQuery<SpanResults<F>>([path, endpointOptions], {
-    staleTime: 0,
-    refetchOnWindowFocus: false,
-    retry: false,
-    enabled,
-  });
-
-  return result;
-}
-
 const LayoutMain = styled(Layout.Main)`
   display: flex;
   flex-direction: column;

+ 1 - 1
static/app/views/traces/fieldRenderers.spec.tsx

@@ -6,7 +6,6 @@ import {act, fireEvent, render, screen} from 'sentry-test/reactTestingLibrary';
 
 import ProjectsStore from 'sentry/stores/projectsStore';
 import type {Project} from 'sentry/types/project';
-import type {SpanResult} from 'sentry/views/traces/content';
 import type {Field} from 'sentry/views/traces/data';
 import {
   ProjectRenderer,
@@ -17,6 +16,7 @@ import {
   TraceIssuesRenderer,
   TransactionRenderer,
 } from 'sentry/views/traces/fieldRenderers';
+import type {SpanResult} from 'sentry/views/traces/hooks/useTraceSpans';
 
 describe('Renderers', function () {
   let context;

+ 3 - 2
static/app/views/traces/fieldRenderers.tsx

@@ -29,7 +29,9 @@ import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transac
 
 import {TraceViewSources} from '../performance/newTraceDetails/traceMetadataHeader';
 
-import type {SpanResult, TraceResult} from './content';
+import type {TraceResult} from './hooks/useTraces';
+import {BREAKDOWN_SLICES} from './hooks/useTraces';
+import type {SpanResult} from './hooks/useTraceSpans';
 import type {Field} from './data';
 import {getShortenedSdkName, getStylingSliceName} from './utils';
 
@@ -281,7 +283,6 @@ export function TraceBreakdownRenderer({
 }
 
 const BREAKDOWN_SIZE_PX = 200;
-export const BREAKDOWN_SLICES = 40;
 
 /**
  * This renders slices in two different ways;

+ 56 - 0
static/app/views/traces/hooks/usePageParams.spec.tsx

@@ -0,0 +1,56 @@
+import {renderHook} from 'sentry-test/reactTestingLibrary';
+
+import {usePageParams} from 'sentry/views/traces/hooks/usePageParams';
+
+describe('usePageParams', function () {
+  it('decodes no queries on page', function () {
+    const location = {query: {}};
+    const {result} = renderHook(() => usePageParams(location), {
+      initialProps: {location},
+    });
+
+    expect(result.current.queries).toEqual([]);
+  });
+
+  it('decodes single query on page', function () {
+    const location = {query: {query: 'query1'}};
+    const {result} = renderHook(() => usePageParams(location), {
+      initialProps: {location},
+    });
+
+    expect(result.current.queries).toEqual(['query1']);
+  });
+
+  it('decodes multiple queries on page', function () {
+    const location = {query: {query: ['query1', 'query2', 'query3']}};
+    const {result} = renderHook(() => usePageParams(location), {
+      initialProps: {location},
+    });
+
+    expect(result.current.queries).toEqual(['query1', 'query2', 'query3']);
+  });
+
+  it('decodes metrics related params', function () {
+    const location = {
+      query: {
+        metricsMax: '456',
+        metricsMin: '123',
+        metricsOp: 'sum',
+        metricsQuery: 'foo:bar',
+        mri: 'd:transactions/duration@millisecond',
+      },
+    };
+    const {result} = renderHook(() => usePageParams(location), {
+      initialProps: {location},
+    });
+
+    expect(result.current).toEqual({
+      queries: [],
+      metricsMax: '456',
+      metricsMin: '123',
+      metricsOp: 'sum',
+      metricsQuery: 'foo:bar',
+      mri: 'd:transactions/duration@millisecond',
+    });
+  });
+});

+ 24 - 0
static/app/views/traces/hooks/usePageParams.tsx

@@ -0,0 +1,24 @@
+import {useMemo} from 'react';
+
+import {decodeList, decodeScalar} from 'sentry/utils/queryString';
+
+export function usePageParams(location) {
+  const queries = useMemo(() => {
+    return decodeList(location.query.query);
+  }, [location.query.query]);
+
+  const metricsMax = decodeScalar(location.query.metricsMax);
+  const metricsMin = decodeScalar(location.query.metricsMin);
+  const metricsOp = decodeScalar(location.query.metricsOp);
+  const metricsQuery = decodeScalar(location.query.metricsQuery);
+  const mri = decodeScalar(location.query.mri);
+
+  return {
+    queries,
+    metricsMax,
+    metricsMin,
+    metricsOp,
+    metricsQuery,
+    mri,
+  };
+}

+ 134 - 0
static/app/views/traces/hooks/useTraceSpans.spec.tsx

@@ -0,0 +1,134 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {makeTestQueryClient} from 'sentry-test/queryClient';
+import {act, renderHook, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import PageFiltersStore from 'sentry/stores/pageFiltersStore';
+import ProjectsStore from 'sentry/stores/projectsStore';
+import type {Organization} from 'sentry/types';
+import {QueryClientProvider} from 'sentry/utils/queryClient';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import type {TraceResult} from 'sentry/views/traces/hooks/useTraces';
+import type {SpanResults} from 'sentry/views/traces/hooks/useTraceSpans';
+import {useTraceSpans} from 'sentry/views/traces/hooks/useTraceSpans';
+
+function createTraceResult(trace?: Partial<TraceResult>): TraceResult {
+  return {
+    breakdowns: [],
+    duration: 333,
+    end: 456,
+    matchingSpans: 1,
+    name: 'name',
+    numErrors: 1,
+    numOccurrences: 1,
+    numSpans: 2,
+    project: 'project',
+    slices: 10,
+    start: 123,
+    trace: '00000000000000000000000000000000',
+    ...trace,
+  };
+}
+
+function createWrapper(organization: Organization) {
+  return function ({children}: {children?: React.ReactNode}) {
+    return (
+      <QueryClientProvider client={makeTestQueryClient()}>
+        <OrganizationContext.Provider value={organization}>
+          {children}
+        </OrganizationContext.Provider>
+      </QueryClientProvider>
+    );
+  };
+}
+
+describe('useTraceSpans', function () {
+  const project = ProjectFixture();
+  const organization = OrganizationFixture();
+  const context = initializeOrg({
+    organization,
+    projects: [project],
+    router: {
+      location: {
+        pathname: '/organizations/org-slug/issues/',
+        query: {project: project.id},
+      },
+      params: {},
+    },
+  });
+
+  beforeEach(function () {
+    MockApiClient.clearMockResponses();
+    act(() => {
+      ProjectsStore.loadInitialData([project]);
+      PageFiltersStore.init();
+      PageFiltersStore.onInitializeUrlState(
+        {
+          projects: [project].map(p => parseInt(p.id, 10)),
+          environments: [],
+          datetime: {
+            period: '3d',
+            start: null,
+            end: null,
+            utc: null,
+          },
+        },
+        new Set()
+      );
+    });
+  });
+
+  it('handles querying the api', async function () {
+    const trace = createTraceResult();
+
+    const body: SpanResults<'id'> = {
+      data: [{id: '0000000000000000'}],
+      meta: {},
+    };
+
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/trace/${trace.trace}/spans/`,
+      body,
+      match: [
+        MockApiClient.matchQuery({
+          project: [parseInt(project.id, 10)],
+          field: ['id'],
+          maxSpansPerTrace: 10,
+          query: 'foo:bar',
+          period: '3d',
+          mri: 'd:transactions/duration@millisecond',
+          metricsMax: '456',
+          metricsMin: '123',
+          metricsOp: 'sum',
+          metricsQuery: 'foo:bar',
+        }),
+      ],
+    });
+
+    const {result} = renderHook(useTraceSpans, {
+      ...context,
+      wrapper: createWrapper(organization),
+      initialProps: {
+        fields: ['id'],
+        trace,
+        datetime: {
+          end: null,
+          period: '3d',
+          start: null,
+          utc: null,
+        },
+        query: 'foo:bar',
+        mri: 'd:transactions/duration@millisecond',
+        metricsMax: '456',
+        metricsMin: '123',
+        metricsOp: 'sum',
+        metricsQuery: 'foo:bar',
+      },
+    });
+
+    await waitFor(() => result.current.isSuccess);
+    expect(result.current.data).toEqual(body);
+  });
+});

+ 76 - 0
static/app/views/traces/hooks/useTraceSpans.tsx

@@ -0,0 +1,76 @@
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import type {PageFilters} from 'sentry/types/core';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+
+import type {TraceResult} from './useTraces';
+
+export type SpanResult<F extends string> = Record<F, any>;
+
+export interface SpanResults<F extends string> {
+  data: SpanResult<F>[];
+  meta: any;
+}
+
+interface UseTraceSpansOptions<F extends string> {
+  fields: F[];
+  trace: TraceResult;
+  datetime?: PageFilters['datetime'];
+  enabled?: boolean;
+  limit?: number;
+  metricsMax?: string;
+  metricsMin?: string;
+  metricsOp?: string;
+  metricsQuery?: string;
+  mri?: string;
+  query?: string | string[];
+  sort?: string[];
+}
+
+export function useTraceSpans<F extends string>({
+  fields,
+  trace,
+  datetime,
+  enabled,
+  limit,
+  mri,
+  metricsMax,
+  metricsMin,
+  metricsOp,
+  metricsQuery,
+  query,
+  sort,
+}: UseTraceSpansOptions<F>) {
+  const organization = useOrganization();
+  const {selection} = usePageFilters();
+
+  const path = `/organizations/${organization.slug}/trace/${trace.trace}/spans/`;
+
+  const endpointOptions = {
+    query: {
+      project: selection.projects,
+      environment: selection.environments,
+      ...(datetime ?? normalizeDateTimeParams(selection.datetime)),
+      field: fields,
+      query,
+      sort,
+      per_page: limit,
+      maxSpansPerTrace: 10,
+      mri,
+      metricsMax,
+      metricsMin,
+      metricsOp,
+      metricsQuery,
+    },
+  };
+
+  const result = useApiQuery<SpanResults<F>>([path, endpointOptions], {
+    staleTime: 0,
+    refetchOnWindowFocus: false,
+    retry: false,
+    enabled,
+  });
+
+  return result;
+}

+ 132 - 0
static/app/views/traces/hooks/useTraces.spec.tsx

@@ -0,0 +1,132 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {makeTestQueryClient} from 'sentry-test/queryClient';
+import {act, renderHook, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import PageFiltersStore from 'sentry/stores/pageFiltersStore';
+import ProjectsStore from 'sentry/stores/projectsStore';
+import type {Organization} from 'sentry/types';
+import {QueryClientProvider} from 'sentry/utils/queryClient';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import type {TraceResult} from 'sentry/views/traces/hooks/useTraces';
+import {useTraces} from 'sentry/views/traces/hooks/useTraces';
+
+function createTraceResult(trace?: Partial<TraceResult>): TraceResult {
+  return {
+    breakdowns: [],
+    duration: 333,
+    end: 456,
+    matchingSpans: 1,
+    name: 'name',
+    numErrors: 1,
+    numOccurrences: 1,
+    numSpans: 2,
+    project: 'project',
+    slices: 10,
+    start: 123,
+    trace: '00000000000000000000000000000000',
+    ...trace,
+  };
+}
+
+function createWrapper(organization: Organization) {
+  return function ({children}: {children?: React.ReactNode}) {
+    return (
+      <QueryClientProvider client={makeTestQueryClient()}>
+        <OrganizationContext.Provider value={organization}>
+          {children}
+        </OrganizationContext.Provider>
+      </QueryClientProvider>
+    );
+  };
+}
+
+describe('useTraces', function () {
+  const project = ProjectFixture();
+  const organization = OrganizationFixture();
+  const context = initializeOrg({
+    organization,
+    projects: [project],
+    router: {
+      location: {
+        pathname: '/organizations/org-slug/issues/',
+        query: {project: project.id},
+      },
+      params: {},
+    },
+  });
+
+  beforeEach(function () {
+    MockApiClient.clearMockResponses();
+    act(() => {
+      ProjectsStore.loadInitialData([project]);
+      PageFiltersStore.init();
+      PageFiltersStore.onInitializeUrlState(
+        {
+          projects: [project].map(p => parseInt(p.id, 10)),
+          environments: [],
+          datetime: {
+            period: '3d',
+            start: null,
+            end: null,
+            utc: null,
+          },
+        },
+        new Set()
+      );
+    });
+  });
+
+  it('handles querying the api', async function () {
+    const trace = createTraceResult();
+
+    const body = {
+      data: [trace],
+      meta: {},
+    };
+
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/traces/`,
+      body,
+      match: [
+        MockApiClient.matchQuery({
+          project: [parseInt(project.id, 10)],
+          query: 'foo:bar',
+          period: '3d',
+          per_page: 10,
+          breakdownSlices: 40,
+          mri: 'd:transactions/duration@millisecond',
+          metricsMax: '456',
+          metricsMin: '123',
+          metricsOp: 'sum',
+          metricsQuery: 'foo:bar',
+        }),
+      ],
+    });
+
+    const {result} = renderHook(useTraces, {
+      ...context,
+      wrapper: createWrapper(organization),
+      initialProps: {
+        datetime: {
+          end: null,
+          period: '3d',
+          start: null,
+          utc: null,
+        },
+        limit: 10,
+        query: 'foo:bar',
+        mri: 'd:transactions/duration@millisecond',
+        metricsMax: '456',
+        metricsMin: '123',
+        metricsOp: 'sum',
+        metricsQuery: 'foo:bar',
+      },
+    });
+
+    await waitFor(() => result.current.isSuccess);
+    expect(result.current.data).toEqual(body);
+  });
+});

+ 163 - 0
static/app/views/traces/hooks/useTraces.tsx

@@ -0,0 +1,163 @@
+import {useEffect} from 'react';
+
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import type {PageFilters} from 'sentry/types/core';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useProjects from 'sentry/utils/useProjects';
+
+export const BREAKDOWN_SLICES = 40;
+
+interface TraceBreakdownBase {
+  duration: number; // Contains the accurate duration for display. Start and end may be quantized.
+  end: number;
+  opCategory: string | null;
+  sdkName: string | null;
+  sliceEnd: number;
+  sliceStart: number;
+  sliceWidth: number;
+  start: number;
+}
+
+type TraceBreakdownProject = TraceBreakdownBase & {
+  kind: 'project';
+  project: string;
+};
+
+type TraceBreakdownMissing = TraceBreakdownBase & {
+  kind: 'missing';
+  project: null;
+};
+
+export interface TraceResult {
+  breakdowns: TraceBreakdownResult[];
+  duration: number;
+  end: number;
+  matchingSpans: number;
+  name: string | null;
+  numErrors: number;
+  numOccurrences: number;
+  numSpans: number;
+  project: string | null;
+  slices: number;
+  start: number;
+  trace: string;
+}
+
+export type TraceBreakdownResult = TraceBreakdownProject | TraceBreakdownMissing;
+
+interface TraceResults {
+  data: TraceResult[];
+  meta: any;
+}
+
+interface UseTracesOptions {
+  datetime?: PageFilters['datetime'];
+  enabled?: boolean;
+  limit?: number;
+  metricsMax?: string;
+  metricsMin?: string;
+  metricsOp?: string;
+  metricsQuery?: string;
+  mri?: string;
+  query?: string | string[];
+  sort?: '-timestamp';
+}
+
+export function useTraces({
+  datetime,
+  enabled,
+  limit,
+  mri,
+  metricsMax,
+  metricsMin,
+  metricsOp,
+  metricsQuery,
+  query,
+  sort,
+}: UseTracesOptions) {
+  const organization = useOrganization();
+  const {projects} = useProjects();
+  const {selection} = usePageFilters();
+
+  const path = `/organizations/${organization.slug}/traces/`;
+
+  const endpointOptions = {
+    query: {
+      project: selection.projects,
+      environment: selection.environments,
+      ...(datetime ?? normalizeDateTimeParams(selection.datetime)),
+      query,
+      sort,
+      per_page: limit,
+      breakdownSlices: BREAKDOWN_SLICES,
+      mri,
+      metricsMax,
+      metricsMin,
+      metricsOp,
+      metricsQuery,
+    },
+  };
+
+  const serializedEndpointOptions = JSON.stringify(endpointOptions);
+
+  let queries: string[] = [];
+  if (Array.isArray(query)) {
+    queries = query;
+  } else if (query !== undefined) {
+    queries = [query];
+  }
+
+  useEffect(() => {
+    trackAnalytics('trace_explorer.search_request', {
+      organization,
+      queries,
+    });
+    // `queries` is already included as a dep in serializedEndpointOptions
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [serializedEndpointOptions, organization]);
+
+  const result = useApiQuery<TraceResults>([path, endpointOptions], {
+    staleTime: 0,
+    refetchOnWindowFocus: false,
+    retry: false,
+    enabled,
+  });
+
+  useEffect(() => {
+    if (result.status === 'success') {
+      const project_slugs = [...new Set(result.data.data.map(trace => trace.project))];
+      const project_platforms = projects
+        .filter(p => project_slugs.includes(p.slug))
+        .map(p => p.platform ?? '');
+
+      trackAnalytics('trace_explorer.search_success', {
+        organization,
+        queries,
+        has_data: result.data.data.length > 0,
+        num_traces: result.data.data.length,
+        num_missing_trace_root: result.data.data.filter(trace => trace.name === null)
+          .length,
+        project_platforms,
+      });
+    } else if (result.status === 'error') {
+      const response = result.error.responseJSON;
+      const error =
+        typeof response?.detail === 'string'
+          ? response?.detail
+          : response?.detail?.message;
+      trackAnalytics('trace_explorer.search_failure', {
+        organization,
+        queries,
+        error: error ?? '',
+      });
+    }
+    // result.status is tied to result.data. No need to explicitly
+    // include result.data as an additional dep.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [serializedEndpointOptions, result.status, organization]);
+
+  return result;
+}

+ 2 - 1
static/app/views/traces/utils.tsx

@@ -2,7 +2,8 @@ import type {Location, LocationDescriptor} from 'history';
 
 import type {Organization} from 'sentry/types/organization';
 
-import type {SpanResult, TraceResult} from './content';
+import type {TraceResult} from './hooks/useTraces';
+import type {SpanResult} from './hooks/useTraceSpans';
 import type {Field} from './data';
 
 export function normalizeTraces(traces: TraceResult[] | undefined) {