Browse Source

feat(explore): Adding trace samples table. (#76946)

- Moved code over entirely to `explore/`. This will make cleanup easier.
- Removed issue column.
- Removed Metrics params.

<img width="1296" alt="Screenshot 2024-09-04 at 2 41 29 PM"
src="https://github.com/user-attachments/assets/cc8247f2-fe97-426d-8a5b-935576f681be">

---------

Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdullah Khan 6 months ago
parent
commit
f07de2b439

+ 125 - 0
static/app/views/explore/hooks/useTraceSpans.spec.tsx

@@ -0,0 +1,125 @@
+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/organization';
+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 './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',
+          statsPeriod: '3d',
+        }),
+      ],
+    });
+
+    const {result} = renderHook(useTraceSpans, {
+      ...context,
+      wrapper: createWrapper(organization),
+      initialProps: {
+        fields: ['id'],
+        trace,
+        datetime: {
+          end: null,
+          period: '3d',
+          start: null,
+          utc: null,
+        },
+        query: 'foo:bar',
+      },
+    });
+
+    await waitFor(() => result.current.isSuccess);
+    expect(result.current.data).toEqual(body);
+  });
+});

+ 61 - 0
static/app/views/explore/hooks/useTraceSpans.tsx

@@ -0,0 +1,61 @@
+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;
+  query?: string | string[];
+  sort?: string[];
+}
+
+export function useTraceSpans<F extends string>({
+  fields,
+  trace,
+  datetime,
+  enabled,
+  limit,
+  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,
+      ...normalizeDateTimeParams(datetime ?? selection.datetime),
+      field: fields,
+      query,
+      sort,
+      per_page: limit,
+      maxSpansPerTrace: 10,
+    },
+  };
+
+  const result = useApiQuery<SpanResults<F>>([path, endpointOptions], {
+    staleTime: 0,
+    refetchOnWindowFocus: false,
+    retry: false,
+    enabled,
+  });
+
+  return result;
+}

+ 122 - 0
static/app/views/explore/hooks/useTraces.spec.tsx

@@ -0,0 +1,122 @@
+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/organization';
+import {QueryClientProvider} from 'sentry/utils/queryClient';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+
+import {type TraceResult, useTraces} from './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',
+          statsPeriod: '3d',
+          per_page: 10,
+          breakdownSlices: 40,
+        }),
+      ],
+    });
+
+    const {result} = renderHook(useTraces, {
+      ...context,
+      wrapper: createWrapper(organization),
+      initialProps: {
+        datetime: {
+          end: null,
+          period: '3d',
+          start: null,
+          utc: null,
+        },
+        limit: 10,
+        query: 'foo:bar',
+      },
+    });
+
+    await waitFor(() => result.current.isSuccess);
+    expect(result.current.data).toEqual(body);
+  });
+});

+ 141 - 0
static/app/views/explore/hooks/useTraces.tsx

@@ -0,0 +1,141 @@
+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;
+  query?: string | string[];
+}
+
+export function useTraces({datetime, enabled, limit, query}: 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,
+      ...normalizeDateTimeParams(datetime ?? selection.datetime),
+      query,
+      per_page: limit,
+      breakdownSlices: BREAKDOWN_SLICES,
+    },
+  };
+
+  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,
+    refetchOnMount: 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;
+}

+ 1 - 1
static/app/views/explore/tables/index.tsx

@@ -11,10 +11,10 @@ import {useResultMode} from 'sentry/views/explore/hooks/useResultsMode';
 import {useSampleFields} from 'sentry/views/explore/hooks/useSampleFields';
 import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
 
+import {TracesTable} from './tracesTable/index';
 import {AggregatesTable} from './aggregatesTable';
 import {ColumnEditorModal} from './columnEditorModal';
 import {SpansTable} from './spansTable';
-import {TracesTable} from './tracesTable';
 
 enum Tab {
   SPAN = 'span',

+ 0 - 5
static/app/views/explore/tables/tracesTable.tsx

@@ -1,5 +0,0 @@
-interface TracesTableProps {}
-
-export function TracesTable({}: TracesTableProps) {
-  return <div>TODO: trace table</div>;
-}

+ 21 - 0
static/app/views/explore/tables/tracesTable/data.tsx

@@ -0,0 +1,21 @@
+export const FIELDS = [
+  'project',
+  'transaction.id',
+  'id',
+  'timestamp',
+  'sdk.name',
+  'span.op',
+  'span.description',
+  'span.duration',
+  'span.status',
+  'span.self_time',
+  'precise.start_ts',
+  'precise.finish_ts',
+  'is_transaction',
+] as const;
+
+export type Field = (typeof FIELDS)[number];
+
+export type Sort = Field | `-${Field}`;
+
+export const SORTS: Sort[] = ['-is_transaction', '-span.self_time'];

+ 569 - 0
static/app/views/explore/tables/tracesTable/fieldRenderers.tsx

@@ -0,0 +1,569 @@
+import {useState} from 'react';
+import {css, type Theme, useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+
+import Tag from 'sentry/components/badge/tag';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Link from 'sentry/components/links/link';
+import {RowRectangle} from 'sentry/components/performance/waterfall/rowBar';
+import {pickBarColor} from 'sentry/components/performance/waterfall/utils';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import TimeSince from 'sentry/components/timeSince';
+import {Tooltip} from 'sentry/components/tooltip';
+import {t, tn} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls';
+import {getShortEventId} from 'sentry/utils/events';
+import Projects from 'sentry/utils/projects';
+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 type {SpanIndexedField, SpanIndexedResponse} from 'sentry/views/insights/types';
+import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceMetadataHeader';
+import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';
+import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
+
+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';
+
+export const ProjectBadgeWrapper = styled('span')`
+  /**
+   * Max of 2 visible projects, 16px each, 2px border, 8px overlap.
+   */
+  width: 32px;
+  min-width: 32px;
+`;
+
+export function SpanDescriptionRenderer({span}: {span: SpanResult<Field>}) {
+  return (
+    <Description data-test-id="span-description">
+      <ProjectBadgeWrapper>
+        <ProjectRenderer projectSlug={span.project} hideName />
+      </ProjectBadgeWrapper>
+      <strong>{span['span.op']}</strong>
+      <em>{'\u2014'}</em>
+      <WrappingText>{span['span.description']}</WrappingText>
+      {<StatusTag status={span['span.status']} />}
+    </Description>
+  );
+}
+
+interface ProjectsRendererProps {
+  projectSlugs: string[];
+  maxVisibleProjects?: number;
+}
+
+export function ProjectsRenderer({
+  projectSlugs,
+  maxVisibleProjects = 2,
+}: ProjectsRendererProps) {
+  const organization = useOrganization();
+
+  return (
+    <Projects orgId={organization.slug} slugs={projectSlugs}>
+      {({projects}) => {
+        const projectAvatars =
+          projects.length > 0 ? projects : projectSlugs.map(slug => ({slug}));
+        const numProjects = projectAvatars.length;
+        const numVisibleProjects =
+          maxVisibleProjects - numProjects >= 0 ? numProjects : maxVisibleProjects - 1;
+        const visibleProjectAvatars = projectAvatars
+          .slice(0, numVisibleProjects)
+          .reverse();
+        const collapsedProjectAvatars = projectAvatars.slice(numVisibleProjects);
+        const numCollapsedProjects = collapsedProjectAvatars.length;
+
+        return (
+          <ProjectList>
+            {numCollapsedProjects > 0 && (
+              <Tooltip
+                skipWrapper
+                title={
+                  <CollapsedProjects>
+                    {tn(
+                      'This trace contains %s more project.',
+                      'This trace contains %s more projects.',
+                      numCollapsedProjects
+                    )}
+                    {collapsedProjectAvatars.map(project => (
+                      <ProjectBadge
+                        key={project.slug}
+                        project={project}
+                        avatarSize={16}
+                      />
+                    ))}
+                  </CollapsedProjects>
+                }
+              >
+                <CollapsedBadge
+                  size={20}
+                  fontSize={10}
+                  data-test-id="collapsed-projects-badge"
+                >
+                  +{numCollapsedProjects}
+                </CollapsedBadge>
+              </Tooltip>
+            )}
+            {visibleProjectAvatars.map(project => (
+              <StyledProjectBadge
+                key={project.slug}
+                hideName
+                project={project}
+                avatarSize={16}
+                avatarProps={{hasTooltip: true, tooltip: project.slug}}
+              />
+            ))}
+          </ProjectList>
+        );
+      }}
+    </Projects>
+  );
+}
+
+const ProjectList = styled('div')`
+  display: flex;
+  align-items: center;
+  flex-direction: row-reverse;
+  justify-content: flex-end;
+  padding-right: 8px;
+`;
+
+const CollapsedProjects = styled('div')`
+  width: 200px;
+  display: flex;
+  flex-direction: column;
+  gap: ${space(0.5)};
+`;
+
+const AvatarStyle = p => css`
+  border: 2px solid ${p.theme.background};
+  margin-right: -8px;
+  cursor: default;
+
+  &:hover {
+    z-index: 1;
+  }
+`;
+
+const StyledProjectBadge = styled(ProjectBadge)`
+  overflow: hidden;
+  z-index: 0;
+  ${AvatarStyle}
+`;
+
+const CollapsedBadge = styled('div')<{fontSize: number; size: number}>`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  position: relative;
+  text-align: center;
+  font-weight: ${p => p.theme.fontWeightBold};
+  background-color: ${p => p.theme.gray200};
+  color: ${p => p.theme.gray300};
+  font-size: ${p => p.fontSize}px;
+  width: ${p => p.size}px;
+  height: ${p => p.size}px;
+  border-radius: ${p => p.theme.borderRadius};
+  ${AvatarStyle}
+`;
+
+interface ProjectRendererProps {
+  projectSlug: string;
+  hideName?: boolean;
+}
+
+export function ProjectRenderer({projectSlug, hideName}: ProjectRendererProps) {
+  const organization = useOrganization();
+
+  return (
+    <Projects orgId={organization.slug} slugs={[projectSlug]}>
+      {({projects}) => {
+        const project = projects.find(p => p.slug === projectSlug);
+        return (
+          <ProjectBadge
+            hideName={hideName}
+            project={project ? project : {slug: projectSlug}}
+            avatarSize={16}
+            avatarProps={{hasTooltip: true, tooltip: projectSlug}}
+          />
+        );
+      }}
+    </Projects>
+  );
+}
+
+const WrappingText = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+  width: auto;
+`;
+
+export const TraceBreakdownContainer = styled('div')<{hoveredIndex?: number}>`
+  position: relative;
+  display: flex;
+  min-width: 200px;
+  height: 15px;
+  background-color: ${p => p.theme.gray100};
+  ${p => `--hoveredSlice-${p.hoveredIndex ?? -1}-translateY: translateY(-3px)`};
+`;
+
+const RectangleTraceBreakdown = styled(RowRectangle)<{
+  sliceColor: string;
+  sliceName: string | null;
+  offset?: number;
+}>`
+  background-color: ${p => p.sliceColor};
+  position: relative;
+  width: 100%;
+  height: 15px;
+  ${p => `
+    filter: var(--highlightedSlice-${p.sliceName}-saturate, var(--defaultSlice-saturate));
+  `}
+  ${p => `
+    opacity: var(--highlightedSlice-${p.sliceName ?? ''}-opacity, var(--defaultSlice-opacity, 1.0));
+  `}
+  ${p => `
+    transform: var(--hoveredSlice-${p.offset}-translateY, var(--highlightedSlice-${p.sliceName ?? ''}-transform, var(--defaultSlice-transform, 1.0)));
+  `}
+  transition: filter,opacity,transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+`;
+
+export function TraceBreakdownRenderer({
+  trace,
+  setHighlightedSliceName,
+}: {
+  setHighlightedSliceName: (sliceName: string) => void;
+
+  trace: TraceResult;
+}) {
+  const theme = useTheme();
+  const [hoveredIndex, setHoveredIndex] = useState(-1);
+
+  return (
+    <TraceBreakdownContainer
+      data-test-id="relative-ops-breakdown"
+      hoveredIndex={hoveredIndex}
+      onMouseLeave={() => setHoveredIndex(-1)}
+    >
+      {trace.breakdowns.map((breakdown, index) => {
+        return (
+          <SpanBreakdownSliceRenderer
+            key={breakdown.start + (breakdown.project ?? t('missing instrumentation'))}
+            sliceName={breakdown.project}
+            sliceStart={breakdown.start}
+            sliceEnd={breakdown.end}
+            sliceDurationReal={breakdown.duration}
+            sliceSecondaryName={breakdown.sdkName}
+            sliceNumberStart={breakdown.sliceStart}
+            sliceNumberWidth={breakdown.sliceWidth}
+            trace={trace}
+            theme={theme}
+            offset={index}
+            onMouseEnter={() => {
+              setHoveredIndex(index);
+              breakdown.project
+                ? setHighlightedSliceName(
+                    getStylingSliceName(breakdown.project, breakdown.sdkName) ?? ''
+                  )
+                : null;
+            }}
+          />
+        );
+      })}
+    </TraceBreakdownContainer>
+  );
+}
+
+const BREAKDOWN_SIZE_PX = 200;
+
+/**
+ * This renders slices in two different ways;
+ * - Slices in the breakdown for the trace. These have slice numbers returned for quantization from the backend.
+ * - Slices derived from span timings. Spans aren't quantized into slices.
+ */
+export function SpanBreakdownSliceRenderer({
+  trace,
+  theme,
+  sliceName,
+  sliceStart,
+  sliceEnd,
+  sliceNumberStart,
+  sliceNumberWidth,
+  sliceDurationReal,
+  sliceSecondaryName,
+  onMouseEnter,
+  offset,
+}: {
+  onMouseEnter: () => void;
+  sliceEnd: number;
+  sliceName: string | null;
+  sliceSecondaryName: string | null;
+  sliceStart: number;
+  theme: Theme;
+  trace: TraceResult;
+  offset?: number;
+  sliceDurationReal?: number;
+  sliceNumberStart?: number;
+  sliceNumberWidth?: number;
+}) {
+  const traceDuration = trace.end - trace.start;
+
+  const sliceDuration = sliceEnd - sliceStart;
+  const pixelsPerSlice = BREAKDOWN_SIZE_PX / BREAKDOWN_SLICES;
+  const relativeSliceStart = sliceStart - trace.start;
+
+  const stylingSliceName = getStylingSliceName(sliceName, sliceSecondaryName);
+  const sliceColor = stylingSliceName ? pickBarColor(stylingSliceName) : theme.gray100;
+
+  const sliceWidth =
+    sliceNumberWidth !== undefined
+      ? pixelsPerSlice * sliceNumberWidth
+      : pixelsPerSlice * Math.ceil(BREAKDOWN_SLICES * (sliceDuration / traceDuration));
+  const sliceOffset =
+    sliceNumberStart !== undefined
+      ? pixelsPerSlice * sliceNumberStart
+      : pixelsPerSlice *
+        Math.floor((BREAKDOWN_SLICES * relativeSliceStart) / traceDuration);
+
+  return (
+    <BreakdownSlice
+      sliceName={sliceName}
+      sliceOffset={sliceOffset}
+      sliceWidth={sliceWidth}
+      onMouseEnter={onMouseEnter}
+    >
+      <Tooltip
+        title={
+          <div>
+            <FlexContainer>
+              {sliceName ? <ProjectRenderer projectSlug={sliceName} hideName /> : null}
+              <strong>{sliceName}</strong>
+              <Subtext>({getShortenedSdkName(sliceSecondaryName)})</Subtext>
+            </FlexContainer>
+            <div>
+              <PerformanceDuration
+                milliseconds={sliceDurationReal ?? sliceDuration}
+                abbreviation
+              />
+            </div>
+          </div>
+        }
+        containerDisplayMode="block"
+      >
+        <RectangleTraceBreakdown
+          sliceColor={sliceColor}
+          sliceName={stylingSliceName}
+          offset={offset}
+        />
+      </Tooltip>
+    </BreakdownSlice>
+  );
+}
+
+const Subtext = styled('span')`
+  font-weight: ${p => p.theme.fontWeightNormal};
+  color: ${p => p.theme.gray300};
+`;
+const FlexContainer = styled('div')`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: ${space(0.5)};
+  padding-bottom: ${space(0.5)};
+`;
+
+const BreakdownSlice = styled('div')<{
+  sliceName: string | null;
+  sliceOffset: number;
+  sliceWidth: number;
+}>`
+  position: absolute;
+  width: max(3px, ${p => p.sliceWidth}px);
+  left: ${p => p.sliceOffset}px;
+  ${p => (p.sliceName ? null : 'z-index: -1;')}
+`;
+
+interface SpanIdRendererProps {
+  projectSlug: string;
+  spanId: string;
+  timestamp: string;
+  traceId: string;
+  transactionId: string;
+  onClick?: () => void;
+}
+
+export function SpanIdRenderer({
+  projectSlug,
+  spanId,
+  timestamp,
+  traceId,
+  transactionId,
+  onClick,
+}: SpanIdRendererProps) {
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const target = generateLinkToEventInTraceView({
+    projectSlug,
+    traceSlug: traceId,
+    timestamp,
+    eventId: transactionId,
+    organization,
+    location,
+    spanId,
+    source: TraceViewSources.TRACES,
+  });
+
+  return (
+    <Link to={target} onClick={onClick}>
+      {getShortEventId(spanId)}
+    </Link>
+  );
+}
+
+interface TraceIdRendererProps {
+  location: Location;
+  timestamp: number; // in milliseconds
+  traceId: string;
+  onClick?: () => void;
+  transactionId?: string;
+}
+
+export function TraceIdRenderer({
+  traceId,
+  timestamp,
+  transactionId,
+  location,
+  onClick,
+}: TraceIdRendererProps) {
+  const organization = useOrganization();
+  const {selection} = usePageFilters();
+
+  const target = getTraceDetailsUrl({
+    organization,
+    traceSlug: traceId,
+    dateSelection: {
+      start: selection.datetime.start,
+      end: selection.datetime.end,
+      statsPeriod: selection.datetime.period,
+    },
+    timestamp: timestamp / 1000,
+    eventId: transactionId,
+    location,
+    source: TraceViewSources.TRACES,
+  });
+
+  return (
+    <Link to={target} style={{minWidth: '66px', textAlign: 'right'}} onClick={onClick}>
+      {getShortEventId(traceId)}
+    </Link>
+  );
+}
+
+interface TransactionRendererProps {
+  projectSlug: string;
+  transaction: string;
+}
+
+export function TransactionRenderer({
+  projectSlug,
+  transaction,
+}: TransactionRendererProps) {
+  const location = useLocation();
+  const organization = useOrganization();
+  const {projects} = useProjects({slugs: [projectSlug]});
+
+  const target = transactionSummaryRouteWithQuery({
+    orgSlug: organization.slug,
+    transaction,
+    query: {
+      ...location.query,
+      query: undefined,
+    },
+    projectID: String(projects[0]?.id ?? ''),
+  });
+
+  return <Link to={target}>{transaction}</Link>;
+}
+
+export function SpanTimeRenderer({
+  timestamp,
+  tooltipShowSeconds,
+}: {
+  timestamp: number;
+  tooltipShowSeconds?: boolean;
+}) {
+  const date = new Date(timestamp);
+  return (
+    <TimeSince
+      unitStyle="extraShort"
+      date={date}
+      tooltipShowSeconds={tooltipShowSeconds}
+    />
+  );
+}
+
+type SpanStatus = SpanIndexedResponse[SpanIndexedField.SPAN_STATUS];
+
+const STATUS_TO_TAG_TYPE: Record<SpanStatus, keyof Theme['tag']> = {
+  ok: 'success',
+  cancelled: 'warning',
+  unknown: 'info',
+  invalid_argument: 'warning',
+  deadline_exceeded: 'error',
+  not_found: 'warning',
+  already_exists: 'warning',
+  permission_denied: 'warning',
+  resource_exhausted: 'warning',
+  failed_precondition: 'warning',
+  aborted: 'warning',
+  out_of_range: 'warning',
+  unimplemented: 'error',
+  internal_error: 'error',
+  unavailable: 'error',
+  data_loss: 'error',
+  unauthenticated: 'warning',
+};
+
+function statusToTagType(status: string) {
+  return STATUS_TO_TAG_TYPE[status];
+}
+
+const OMITTED_SPAN_STATUS = ['unknown'];
+
+/**
+ * This display a tag for the status (not to be confused with 'status_code' which has values like '200', '429').
+ */
+export function StatusTag({status, onClick}: {status: string; onClick?: () => void}) {
+  const tagType = statusToTagType(status);
+
+  if (!tagType) {
+    return null;
+  }
+
+  if (OMITTED_SPAN_STATUS.includes(status)) {
+    return null;
+  }
+  return (
+    <StyledTag type={tagType} onClick={onClick} borderStyle="solid">
+      {status}
+    </StyledTag>
+  );
+}
+
+const StyledTag = styled(Tag)`
+  cursor: ${p => (p.onClick ? 'pointer' : 'default')};
+`;
+
+export const Description = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: ${space(1)};
+`;

+ 269 - 0
static/app/views/explore/tables/tracesTable/index.tsx

@@ -0,0 +1,269 @@
+import {Fragment, useCallback, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
+
+import {Button} from 'sentry/components/button';
+import Count from 'sentry/components/count';
+import EmptyStateWarning, {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
+import ExternalLink from 'sentry/components/links/externalLink';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {DEFAULT_PER_PAGE, SPAN_PROPS_DOCS_URL} from 'sentry/constants';
+import {IconChevron} from 'sentry/icons/iconChevron';
+import {IconWarning} from 'sentry/icons/iconWarning';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
+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 {type TraceResult, useTraces} from '../../hooks/useTraces';
+import {useUserQuery} from '../../hooks/useUserQuery';
+
+import {
+  Description,
+  ProjectBadgeWrapper,
+  ProjectsRenderer,
+  SpanTimeRenderer,
+  TraceBreakdownRenderer,
+  TraceIdRenderer,
+} from './fieldRenderers';
+import {SpanTable} from './spansTable';
+import {
+  BreakdownPanelItem,
+  EmptyStateText,
+  EmptyValueContainer,
+  StyledPanel,
+  StyledPanelHeader,
+  StyledPanelItem,
+  TracePanelContent,
+  WrappingText,
+} from './styles';
+
+export function TracesTable() {
+  const [query] = useUserQuery();
+  const {data, isLoading, isError} = useTraces({
+    query,
+    limit: DEFAULT_PER_PAGE,
+  });
+
+  const showErrorState = useMemo(() => {
+    return !isLoading && isError;
+  }, [isLoading, isError]);
+
+  const showEmptyState = useMemo(() => {
+    return !isLoading && !showErrorState && (data?.data?.length ?? 0) === 0;
+  }, [data, isLoading, showErrorState]);
+
+  return (
+    <StyledPanel>
+      <TracePanelContent>
+        <StyledPanelHeader align="left" lightText>
+          {t('Trace ID')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="left" lightText>
+          {t('Trace Root')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="right" lightText>
+          {!query ? t('Total Spans') : t('Matching Spans')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="left" lightText>
+          {t('Timeline')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="right" lightText>
+          {t('Duration')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="right" lightText>
+          {t('Timestamp')}
+        </StyledPanelHeader>
+        {isLoading && (
+          <StyledPanelItem span={6} overflow>
+            <LoadingIndicator />
+          </StyledPanelItem>
+        )}
+        {showErrorState && ( // TODO: need an error state
+          <StyledPanelItem span={7} overflow>
+            <EmptyStreamWrapper>
+              <IconWarning color="gray300" size="lg" />
+            </EmptyStreamWrapper>
+          </StyledPanelItem>
+        )}
+        {showEmptyState && (
+          <StyledPanelItem span={7} overflow>
+            <EmptyStateWarning withIcon>
+              <EmptyStateText size="fontSizeExtraLarge">
+                {t('No trace results found')}
+              </EmptyStateText>
+              <EmptyStateText size="fontSizeMedium">
+                {tct('Try adjusting your filters or refer to [docSearchProps].', {
+                  docSearchProps: (
+                    <ExternalLink href={SPAN_PROPS_DOCS_URL}>
+                      {t('docs for search properties')}
+                    </ExternalLink>
+                  ),
+                })}
+              </EmptyStateText>
+            </EmptyStateWarning>
+          </StyledPanelItem>
+        )}
+        {data?.data?.map((trace, i) => (
+          <TraceRow
+            key={trace.trace}
+            trace={trace}
+            defaultExpanded={query && i === 0}
+            query={query}
+          />
+        ))}
+      </TracePanelContent>
+    </StyledPanel>
+  );
+}
+
+function TraceRow({
+  defaultExpanded,
+  trace,
+  query,
+}: {
+  defaultExpanded;
+  query: string;
+  trace: TraceResult;
+}) {
+  const {selection} = usePageFilters();
+  const {projects} = useProjects();
+  const [expanded, setExpanded] = useState<boolean>(defaultExpanded);
+  const [highlightedSliceName, _setHighlightedSliceName] = useState('');
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const setHighlightedSliceName = useMemo(
+    () =>
+      debounce(sliceName => _setHighlightedSliceName(sliceName), 100, {
+        leading: true,
+      }),
+    [_setHighlightedSliceName]
+  );
+
+  const onClickExpand = useCallback(() => setExpanded(e => !e), [setExpanded]);
+
+  const selectedProjects = useMemo(() => {
+    const selectedProjectIds = new Set(
+      selection.projects.map(project => project.toString())
+    );
+    return new Set(
+      projects
+        .filter(project => selectedProjectIds.has(project.id))
+        .map(project => project.slug)
+    );
+  }, [projects, selection.projects]);
+
+  const traceProjects = useMemo(() => {
+    const seenProjects: Set<string> = new Set();
+
+    const leadingProjects: string[] = [];
+    const trailingProjects: string[] = [];
+
+    for (let i = 0; i < trace.breakdowns.length; i++) {
+      const project = trace.breakdowns[i].project;
+      if (!defined(project) || seenProjects.has(project)) {
+        continue;
+      }
+      seenProjects.add(project);
+
+      // Priotize projects that are selected in the page filters
+      if (selectedProjects.has(project)) {
+        leadingProjects.push(project);
+      } else {
+        trailingProjects.push(project);
+      }
+    }
+
+    return [...leadingProjects, ...trailingProjects];
+  }, [selectedProjects, trace]);
+
+  return (
+    <Fragment>
+      <StyledPanelItem align="center" center onClick={onClickExpand}>
+        <StyledButton
+          icon={<IconChevron size="xs" direction={expanded ? 'down' : 'right'} />}
+          aria-label={t('Toggle trace details')}
+          aria-expanded={expanded}
+          size="zero"
+          borderless
+          onClick={() =>
+            trackAnalytics('trace_explorer.toggle_trace_details', {
+              organization,
+              expanded,
+            })
+          }
+        />
+        <TraceIdRenderer
+          traceId={trace.trace}
+          timestamp={trace.end}
+          onClick={() =>
+            trackAnalytics('trace_explorer.open_trace', {
+              organization,
+            })
+          }
+          location={location}
+        />
+      </StyledPanelItem>
+      <StyledPanelItem align="left" overflow>
+        <Description>
+          <ProjectBadgeWrapper>
+            <ProjectsRenderer
+              projectSlugs={
+                traceProjects.length > 0
+                  ? traceProjects
+                  : trace.project
+                    ? [trace.project]
+                    : []
+              }
+            />
+          </ProjectBadgeWrapper>
+          {trace.name ? (
+            <WrappingText>{trace.name}</WrappingText>
+          ) : (
+            <EmptyValueContainer>{t('Missing Trace Root')}</EmptyValueContainer>
+          )}
+        </Description>
+      </StyledPanelItem>
+      <StyledPanelItem align="right">
+        {query ? (
+          tct('[numerator][space]of[space][denominator]', {
+            numerator: <Count value={trace.matchingSpans} />,
+            denominator: <Count value={trace.numSpans} />,
+            space: <Fragment>&nbsp;</Fragment>,
+          })
+        ) : (
+          <Count value={trace.numSpans} />
+        )}
+      </StyledPanelItem>
+      <BreakdownPanelItem
+        align="right"
+        highlightedSliceName={highlightedSliceName}
+        onMouseLeave={() => setHighlightedSliceName('')}
+      >
+        <TraceBreakdownRenderer
+          trace={trace}
+          setHighlightedSliceName={setHighlightedSliceName}
+        />
+      </BreakdownPanelItem>
+      <StyledPanelItem align="right">
+        <PerformanceDuration milliseconds={trace.duration} abbreviation />
+      </StyledPanelItem>
+      <StyledPanelItem align="right">
+        <SpanTimeRenderer timestamp={trace.end} tooltipShowSeconds />
+      </StyledPanelItem>
+      {expanded && (
+        <SpanTable trace={trace} setHighlightedSliceName={setHighlightedSliceName} />
+      )}
+    </Fragment>
+  );
+}
+
+const StyledButton = styled(Button)`
+  margin-right: ${space(0.5)};
+`;

+ 196 - 0
static/app/views/explore/tables/tracesTable/spansTable.tsx

@@ -0,0 +1,196 @@
+import {Fragment, useMemo} from 'react';
+import {useTheme} from '@emotion/react';
+import moment from 'moment-timezone';
+
+import Count from 'sentry/components/count';
+import {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {IconWarning} from 'sentry/icons/iconWarning';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {getUtcDateString} from 'sentry/utils/dates';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import type {TraceResult} from '../../hooks/useTraces';
+import {type SpanResult, useTraceSpans} from '../../hooks/useTraceSpans';
+import {useUserQuery} from '../../hooks/useUserQuery';
+
+import {type Field, FIELDS, SORTS} from './data';
+import {
+  SpanBreakdownSliceRenderer,
+  SpanDescriptionRenderer,
+  SpanIdRenderer,
+  SpanTimeRenderer,
+  TraceBreakdownContainer,
+} from './fieldRenderers';
+import {
+  MoreMatchingSpans,
+  SpanPanelContent,
+  SpanTablePanelItem,
+  StyledPanel,
+  StyledPanelHeader,
+  StyledPanelItem,
+  StyledSpanPanelItem,
+} from './styles';
+import {getSecondaryNameFromSpan, getStylingSliceName} from './utils';
+
+const ONE_MINUTE = 60 * 1000; // in milliseconds
+
+export function SpanTable({
+  trace,
+  setHighlightedSliceName,
+}: {
+  setHighlightedSliceName: (sliceName: string) => void;
+  trace: TraceResult;
+}) {
+  const organization = useOrganization();
+
+  const [query] = useUserQuery();
+
+  const {data, isLoading, isError} = useTraceSpans({
+    trace,
+    fields: [
+      ...FIELDS,
+      ...SORTS.map(field =>
+        field.startsWith('-') ? (field.substring(1) as Field) : (field as Field)
+      ),
+    ],
+    datetime: {
+      // give a 1 minute buffer on each side so that start != end
+      start: getUtcDateString(moment(trace.start - ONE_MINUTE)),
+      end: getUtcDateString(moment(trace.end + ONE_MINUTE)),
+      period: null,
+      utc: true,
+    },
+    limit: 10,
+    query,
+    sort: SORTS,
+  });
+
+  const spans = useMemo(() => data?.data ?? [], [data]);
+
+  const showErrorState = useMemo(() => {
+    return !isLoading && isError;
+  }, [isLoading, isError]);
+
+  const hasData = useMemo(() => {
+    return !isLoading && !showErrorState && spans.length > 0;
+  }, [spans, isLoading, showErrorState]);
+
+  return (
+    <SpanTablePanelItem span={6} overflow>
+      <StyledPanel>
+        <SpanPanelContent>
+          <StyledPanelHeader align="left" lightText>
+            {t('Span ID')}
+          </StyledPanelHeader>
+          <StyledPanelHeader align="left" lightText>
+            {t('Span Description')}
+          </StyledPanelHeader>
+          <StyledPanelHeader align="right" lightText />
+          <StyledPanelHeader align="right" lightText>
+            {t('Span Duration')}
+          </StyledPanelHeader>
+          <StyledPanelHeader align="right" lightText>
+            {t('Timestamp')}
+          </StyledPanelHeader>
+          {isLoading && (
+            <StyledPanelItem span={5} overflow>
+              <LoadingIndicator />
+            </StyledPanelItem>
+          )}
+          {isError && ( // TODO: need an error state
+            <StyledPanelItem span={5} overflow>
+              <EmptyStreamWrapper>
+                <IconWarning color="gray300" size="lg" />
+              </EmptyStreamWrapper>
+            </StyledPanelItem>
+          )}
+          {data?.data.map(span => (
+            <SpanRow
+              organization={organization}
+              key={span.id}
+              span={span}
+              trace={trace}
+              setHighlightedSliceName={setHighlightedSliceName}
+            />
+          ))}
+          {hasData && spans.length < trace.matchingSpans && (
+            <MoreMatchingSpans span={5}>
+              {tct('[more][space]more [matching]spans can be found in the trace.', {
+                more: <Count value={trace.matchingSpans - spans.length} />,
+                space: <Fragment>&nbsp;</Fragment>,
+                matching: query ? 'matching ' : '',
+              })}
+            </MoreMatchingSpans>
+          )}
+        </SpanPanelContent>
+      </StyledPanel>
+    </SpanTablePanelItem>
+  );
+}
+
+function SpanRow({
+  organization,
+  span,
+  trace,
+  setHighlightedSliceName,
+}: {
+  organization: Organization;
+  setHighlightedSliceName: (sliceName: string) => void;
+  span: SpanResult<Field>;
+
+  trace: TraceResult;
+}) {
+  const theme = useTheme();
+  return (
+    <Fragment>
+      <StyledSpanPanelItem align="right">
+        <SpanIdRenderer
+          projectSlug={span.project}
+          transactionId={span['transaction.id']}
+          spanId={span.id}
+          traceId={trace.trace}
+          timestamp={span.timestamp}
+          onClick={() =>
+            trackAnalytics('trace_explorer.open_trace_span', {
+              organization,
+            })
+          }
+        />
+      </StyledSpanPanelItem>
+      <StyledSpanPanelItem align="left" overflow>
+        <SpanDescriptionRenderer span={span} />
+      </StyledSpanPanelItem>
+      <StyledSpanPanelItem align="right" onMouseLeave={() => setHighlightedSliceName('')}>
+        <TraceBreakdownContainer>
+          <SpanBreakdownSliceRenderer
+            sliceName={span.project}
+            sliceSecondaryName={getSecondaryNameFromSpan(span)}
+            sliceStart={Math.ceil(span['precise.start_ts'] * 1000)}
+            sliceEnd={Math.floor(span['precise.finish_ts'] * 1000)}
+            trace={trace}
+            theme={theme}
+            onMouseEnter={() =>
+              setHighlightedSliceName(
+                getStylingSliceName(span.project, getSecondaryNameFromSpan(span)) ?? ''
+              )
+            }
+          />
+        </TraceBreakdownContainer>
+      </StyledSpanPanelItem>
+      <StyledSpanPanelItem align="right">
+        <PerformanceDuration milliseconds={span['span.duration']} abbreviation />
+      </StyledSpanPanelItem>
+
+      <StyledSpanPanelItem align="right">
+        <SpanTimeRenderer
+          timestamp={span['precise.finish_ts'] * 1000}
+          tooltipShowSeconds
+        />
+      </StyledSpanPanelItem>
+    </Fragment>
+  );
+}

Some files were not shown because too many files changed in this diff