Browse Source

feat(performance): Queues module landing page, summary page, and samples slideout content (#69516)

Adds queues landing page content, summary page content, and samples
slideout content.
edwardgou-sentry 10 months ago
parent
commit
69d524039a

+ 84 - 0
static/app/views/performance/queues/destinationSummary/destinationSummaryPage.spec.tsx

@@ -0,0 +1,84 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary';
+
+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 DestinationSummaryPageWithProviders from 'sentry/views/performance/queues/destinationSummary/destinationSummaryPage';
+
+jest.mock('sentry/utils/useLocation');
+jest.mock('sentry/utils/usePageFilters');
+jest.mock('sentry/utils/useOrganization');
+jest.mock('sentry/utils/useProjects');
+
+describe('destinationSummaryPage', () => {
+  const organization = OrganizationFixture({features: ['performance-queues-view']});
+
+  jest.mocked(usePageFilters).mockReturnValue({
+    isReady: true,
+    desyncedFilters: new Set(),
+    pinnedFilters: new Set(),
+    shouldPersist: true,
+    selection: {
+      datetime: {
+        period: '10d',
+        start: null,
+        end: null,
+        utc: false,
+      },
+      environments: [],
+      projects: [],
+    },
+  });
+
+  jest.mocked(useLocation).mockReturnValue({
+    pathname: '',
+    search: '',
+    query: {statsPeriod: '10d', project: '1'},
+    hash: '',
+    state: undefined,
+    action: 'PUSH',
+    key: '',
+  });
+
+  jest.mocked(useOrganization).mockReturnValue(organization);
+
+  jest.mocked(useProjects).mockReturnValue({
+    projects: [],
+    onSearch: jest.fn(),
+    placeholders: [],
+    fetching: false,
+    hasMore: null,
+    fetchError: null,
+    initiallyLoaded: false,
+  });
+
+  let eventsMock, eventsStatsMock;
+
+  beforeEach(() => {
+    eventsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      method: 'GET',
+      body: {data: []},
+    });
+
+    eventsStatsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events-stats/`,
+      method: 'GET',
+      body: {data: []},
+    });
+  });
+
+  it('renders', async () => {
+    render(<DestinationSummaryPageWithProviders />);
+    await screen.findByRole('table', {name: 'Transactions'});
+    await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading-indicator'));
+    screen.getByPlaceholderText('Search for events, users, tags, and more');
+    screen.getByText('Avg Latency');
+    screen.getByText('Published vs Processed');
+    expect(eventsStatsMock).toHaveBeenCalled();
+    expect(eventsMock).toHaveBeenCalled();
+  });
+});

+ 97 - 0
static/app/views/performance/queues/destinationSummary/destinationSummaryPage.tsx

@@ -10,13 +10,24 @@ import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
 import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
 import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
+import SmartSearchBar from 'sentry/components/smartSearchBar';
 import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {DurationUnit} from 'sentry/utils/discover/fields';
 import {decodeScalar} from 'sentry/utils/queryString';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {useOnboardingProject} from 'sentry/views/performance/browser/webVitals/utils/useOnboardingProject';
+import {MetricReadout} from 'sentry/views/performance/metricReadout';
 import * as ModuleLayout from 'sentry/views/performance/moduleLayout';
 import {ModulePageProviders} from 'sentry/views/performance/modulePageProviders';
+import Onboarding from 'sentry/views/performance/onboarding';
+import {LatencyChart} from 'sentry/views/performance/queues/charts/latencyChart';
+import {ThroughputChart} from 'sentry/views/performance/queues/charts/throughputChart';
+import {TransactionsTable} from 'sentry/views/performance/queues/destinationSummary/transactionsTable';
+import {MessageConsumerSamplesPanel} from 'sentry/views/performance/queues/messageConsumerSamplesPanel';
+import {useQueuesMetricsQuery} from 'sentry/views/performance/queues/queries/useQueuesMetricsQuery';
 import {
   DESTINATION_TITLE,
   MODULE_TITLE,
@@ -25,10 +36,12 @@ import {
 
 function DestinationSummaryPage() {
   const organization = useOrganization();
+  const onboardingProject = useOnboardingProject();
 
   const {query} = useLocation();
   const destination = decodeScalar(query.destination);
 
+  const {data} = useQueuesMetricsQuery({destination});
   return (
     <Fragment>
       <Layout.Header>
@@ -42,6 +55,10 @@ function DestinationSummaryPage() {
               },
               {
                 label: MODULE_TITLE,
+                to: normalizeUrl(
+                  `/organizations/${organization.slug}/performance/queues/`
+                ),
+                preservePageFilters: true,
               },
               {
                 label: DESTINATION_TITLE,
@@ -71,11 +88,79 @@ function DestinationSummaryPage() {
                   <EnvironmentPageFilter />
                   <DatePageFilter />
                 </PageFilterBar>
+
+                {!onboardingProject && (
+                  <MetricsRibbon>
+                    <MetricReadout
+                      title={t('Avg Time In Queue')}
+                      value={undefined}
+                      unit={DurationUnit.MILLISECOND}
+                      isLoading={false}
+                    />
+                    <MetricReadout
+                      title={t('Avg Processing Latency')}
+                      value={
+                        data[0]?.['avg_if(span.self_time,span.op,queue.task.celery)']
+                      }
+                      unit={DurationUnit.MILLISECOND}
+                      isLoading={false}
+                    />
+                    <MetricReadout
+                      title={t('Error Rate')}
+                      value={undefined}
+                      unit={'percentage'}
+                      isLoading={false}
+                    />
+                    <MetricReadout
+                      title={t('Published')}
+                      value={data[0]?.['count_op(queue.submit.celery)']}
+                      unit={'count'}
+                      isLoading={false}
+                    />
+                    <MetricReadout
+                      title={t('Processed')}
+                      value={data[0]?.['count_op(queue.task.celery)']}
+                      unit={'count'}
+                      isLoading={false}
+                    />
+                    <MetricReadout
+                      title={t('Time Spent')}
+                      value={data[0]?.['sum(span.self_time)']}
+                      unit={DurationUnit.MILLISECOND}
+                      isLoading={false}
+                    />
+                  </MetricsRibbon>
+                )}
               </HeaderContainer>
             </ModuleLayout.Full>
+
+            {onboardingProject && (
+              <Onboarding organization={organization} project={onboardingProject} />
+            )}
+
+            {!onboardingProject && (
+              <Fragment>
+                <ModuleLayout.Half>
+                  <LatencyChart />
+                </ModuleLayout.Half>
+
+                <ModuleLayout.Half>
+                  <ThroughputChart />
+                </ModuleLayout.Half>
+
+                <ModuleLayout.Full>
+                  <Flex>
+                    {/* TODO: Make search bar work */}
+                    <SmartSearchBar />
+                    <TransactionsTable />
+                  </Flex>
+                </ModuleLayout.Full>
+              </Fragment>
+            )}
           </ModuleLayout.Layout>
         </Layout.Main>
       </Layout.Body>
+      <MessageConsumerSamplesPanel />
     </Fragment>
   );
 }
@@ -93,6 +178,18 @@ function DestinationSummaryPageWithProviders() {
 }
 export default DestinationSummaryPageWithProviders;
 
+const Flex = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(2)};
+`;
+
+const MetricsRibbon = styled('div')`
+  display: flex;
+  flex-wrap: wrap;
+  gap: ${space(4)};
+`;
+
 const HeaderContainer = styled('div')`
   display: flex;
   justify-content: space-between;

+ 103 - 0
static/app/views/performance/queues/messageConsumerSamplesPanel.spec.tsx

@@ -0,0 +1,103 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {MessageConsumerSamplesPanel} from 'sentry/views/performance/queues/messageConsumerSamplesPanel';
+
+jest.mock('sentry/utils/useLocation');
+jest.mock('sentry/utils/usePageFilters');
+jest.mock('sentry/utils/useOrganization');
+
+describe('messageConsumerSamplesPanel', () => {
+  const organization = OrganizationFixture();
+
+  let eventsRequestMock, eventsStatsRequestMock;
+
+  jest.mocked(usePageFilters).mockReturnValue({
+    isReady: true,
+    desyncedFilters: new Set(),
+    pinnedFilters: new Set(),
+    shouldPersist: true,
+    selection: {
+      datetime: {
+        period: '10d',
+        start: null,
+        end: null,
+        utc: false,
+      },
+      environments: [],
+      projects: [],
+    },
+  });
+
+  jest.mocked(useLocation).mockReturnValue({
+    pathname: '',
+    search: '',
+    query: {transaction: 'sentry.tasks.store.save_event', destination: 'event-queue'},
+    hash: '',
+    state: undefined,
+    action: 'PUSH',
+    key: '',
+  });
+
+  jest.mocked(useOrganization).mockReturnValue(organization);
+
+  beforeEach(() => {
+    eventsStatsRequestMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events-stats/`,
+      method: 'GET',
+      body: {
+        data: [[1699907700, [{count: 7810}]]],
+        meta: {},
+      },
+    });
+
+    eventsRequestMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      method: 'GET',
+      body: {
+        data: [],
+        meta: {},
+      },
+    });
+  });
+
+  afterAll(() => {
+    jest.resetAllMocks();
+  });
+
+  it('renders', () => {
+    render(<MessageConsumerSamplesPanel />);
+    expect(eventsStatsRequestMock).toHaveBeenCalled();
+    expect(eventsRequestMock).toHaveBeenCalledWith(
+      `/organizations/${organization.slug}/events/`,
+      expect.objectContaining({
+        method: 'GET',
+        query: expect.objectContaining({
+          dataset: 'spansMetrics',
+          environment: [],
+          field: [
+            'count()',
+            'count_op(queue.submit.celery)',
+            'count_op(queue.task.celery)',
+            'sum(span.self_time)',
+            'avg(span.self_time)',
+            'avg_if(span.self_time,span.op,queue.submit.celery)',
+            'avg_if(span.self_time,span.op,queue.task.celery)',
+          ],
+          per_page: 10,
+          project: [],
+          // TODO: This query filters on transaction twice because `destination` is not an implemented tag yet, and `transaction` is being used as a substitute.
+          // Update this test to check for filtering on `destination` when available.
+          query:
+            'span.op:[queue.task.celery,queue.submit.celery] transaction:event-queue transaction:sentry.tasks.store.save_event',
+          statsPeriod: '10d',
+        }),
+      })
+    );
+    expect(screen.getByRole('table', {name: 'Span Samples'})).toBeInTheDocument();
+  });
+});

+ 273 - 0
static/app/views/performance/queues/messageConsumerSamplesPanel.tsx

@@ -0,0 +1,273 @@
+import {Link} from 'react-router';
+import styled from '@emotion/styled';
+import * as qs from 'query-string';
+
+import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
+import {Button} from 'sentry/components/button';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {DurationUnit} from 'sentry/utils/discover/fields';
+import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import useLocationQuery from 'sentry/utils/url/useLocationQuery';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+import useRouter from 'sentry/utils/useRouter';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {AverageValueMarkLine} from 'sentry/views/performance/charts/averageValueMarkLine';
+import {DurationChart} from 'sentry/views/performance/http/charts/durationChart';
+import {useSpanSamples} from 'sentry/views/performance/http/data/useSpanSamples';
+import {useDebouncedState} from 'sentry/views/performance/http/useDebouncedState';
+import {MetricReadout} from 'sentry/views/performance/metricReadout';
+import * as ModuleLayout from 'sentry/views/performance/moduleLayout';
+import {MessageSpanSamplesTable} from 'sentry/views/performance/queues/messageSpanSamplesTable';
+import {useQueuesMetricsQuery} from 'sentry/views/performance/queues/queries/useQueuesMetricsQuery';
+import {computeAxisMax} from 'sentry/views/starfish/components/chart';
+import DetailPanel from 'sentry/views/starfish/components/detailPanel';
+import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useSpanMetricsSeries';
+import {useSampleScatterPlotSeries} from 'sentry/views/starfish/views/spanSummaryPage/sampleList/durationChart/useSampleScatterPlotSeries';
+
+// We're defining our own query filter here, apart from settings.ts because the spans endpoint doesn't accept IN operations
+const DEFAULT_QUERY_FILTER = 'span.op:queue.task.celery OR span.op:queue.submit.celery';
+
+export function MessageConsumerSamplesPanel() {
+  const router = useRouter();
+  const query = useLocationQuery({
+    fields: {
+      project: decodeScalar,
+      destination: decodeScalar,
+      transaction: decodeScalar,
+    },
+  });
+  const {projects} = useProjects();
+  const project = projects.find(p => query.project === p.id);
+
+  const organization = useOrganization();
+
+  const [highlightedSpanId, setHighlightedSpanId] = useDebouncedState<string | undefined>(
+    undefined,
+    [],
+    SAMPLE_HOVER_DEBOUNCE
+  );
+
+  // `detailKey` controls whether the panel is open. If all required properties are available, concat them to make a key, otherwise set to `undefined` and hide the panel
+  const detailKey = query.transaction
+    ? [query.destination, query.transaction].filter(Boolean).join(':')
+    : undefined;
+
+  const isPanelOpen = Boolean(detailKey);
+
+  // TODO: This should also filter on destination
+  const search = new MutableSearch(DEFAULT_QUERY_FILTER);
+  search.addFilterValue('transaction', query.transaction);
+
+  const {data: transactionMetrics, isFetching: aretransactionMetricsFetching} =
+    useQueuesMetricsQuery({
+      destination: query.destination,
+      transaction: query.transaction,
+      enabled: isPanelOpen,
+    });
+
+  const {
+    isFetching: isDurationDataFetching,
+    data: durationData,
+    error: durationError,
+  } = useSpanMetricsSeries({
+    search,
+    yAxis: [`avg(span.self_time)`],
+    enabled: isPanelOpen,
+  });
+
+  const durationAxisMax = computeAxisMax([durationData?.[`avg(span.self_time)`]]);
+
+  const {
+    data: durationSamplesData,
+    isFetching: isDurationSamplesDataFetching,
+    error: durationSamplesDataError,
+    refetch: refetchDurationSpanSamples,
+  } = useSpanSamples({
+    search,
+    min: 0,
+    max: durationAxisMax,
+    enabled: isPanelOpen && durationAxisMax > 0,
+  });
+
+  const sampledSpanDataSeries = useSampleScatterPlotSeries(
+    durationSamplesData,
+    transactionMetrics?.[0]?.['avg(span.self_time)'],
+    highlightedSpanId
+  );
+
+  const findSampleFromDataPoint = (dataPoint: {name: string | number; value: number}) => {
+    return durationSamplesData.find(
+      s => s.timestamp === dataPoint.name && s['span.self_time'] === dataPoint.value
+    );
+  };
+
+  const handleClose = () => {
+    router.replace({
+      pathname: router.location.pathname,
+      query: {
+        ...router.location.query,
+        transaction: undefined,
+        transactionMethod: undefined,
+      },
+    });
+  };
+
+  return (
+    <PageAlertProvider>
+      <DetailPanel detailKey={detailKey} onClose={handleClose}>
+        <ModuleLayout.Layout>
+          <ModuleLayout.Full>
+            <HeaderContainer>
+              {project && (
+                <SpanSummaryProjectAvatar
+                  project={project}
+                  direction="left"
+                  size={40}
+                  hasTooltip
+                  tooltip={project.slug}
+                />
+              )}
+              <TitleContainer>
+                <Title>
+                  <Link
+                    to={normalizeUrl(
+                      `/organizations/${organization.slug}/performance/summary?${qs.stringify(
+                        {
+                          project: query.project,
+                          transaction: query.transaction,
+                        }
+                      )}`
+                    )}
+                  >
+                    {query.transaction}
+                  </Link>
+                </Title>
+              </TitleContainer>
+            </HeaderContainer>
+          </ModuleLayout.Full>
+
+          <ModuleLayout.Full>
+            <MetricsRibbon>
+              <MetricReadout
+                align="left"
+                title={t('Processed')}
+                value={transactionMetrics?.[0]?.['count()']}
+                unit={'count'}
+                isLoading={aretransactionMetricsFetching}
+              />
+              <MetricReadout
+                align="left"
+                title={t('Error Rate')}
+                value={undefined}
+                unit={'percentage'}
+                isLoading={aretransactionMetricsFetching}
+              />
+              <MetricReadout
+                title={t('Avg Time In Queue')}
+                value={undefined}
+                unit={DurationUnit.MILLISECOND}
+                isLoading={false}
+              />
+              <MetricReadout
+                title={t('Avg Processing Latency')}
+                value={
+                  transactionMetrics[0]?.[
+                    'avg_if(span.self_time,span.op,queue.task.celery)'
+                  ]
+                }
+                unit={DurationUnit.MILLISECOND}
+                isLoading={false}
+              />
+            </MetricsRibbon>
+          </ModuleLayout.Full>
+          <ModuleLayout.Full>
+            <DurationChart
+              series={[
+                {
+                  ...durationData[`avg(span.self_time)`],
+                  markLine: AverageValueMarkLine(),
+                },
+              ]}
+              scatterPlot={sampledSpanDataSeries}
+              onHighlight={highlights => {
+                const firstHighlight = highlights[0];
+
+                if (!firstHighlight) {
+                  setHighlightedSpanId(undefined);
+                  return;
+                }
+
+                const sample = findSampleFromDataPoint(firstHighlight.dataPoint);
+                setHighlightedSpanId(sample?.span_id);
+              }}
+              isLoading={isDurationDataFetching}
+              error={durationError}
+            />
+          </ModuleLayout.Full>
+
+          <ModuleLayout.Full>
+            <MessageSpanSamplesTable
+              data={durationSamplesData}
+              isLoading={isDurationDataFetching || isDurationSamplesDataFetching}
+              highlightedSpanId={highlightedSpanId}
+              onSampleMouseOver={sample => setHighlightedSpanId(sample.span_id)}
+              onSampleMouseOut={() => setHighlightedSpanId(undefined)}
+              error={durationSamplesDataError}
+              // Samples endpoint doesn't provide meta data, so we need to provide it here
+              meta={{
+                fields: {
+                  'span.self_time': 'duration',
+                },
+                units: {},
+              }}
+            />
+          </ModuleLayout.Full>
+
+          <ModuleLayout.Full>
+            <Button onClick={() => refetchDurationSpanSamples()}>
+              {t('Try Different Samples')}
+            </Button>
+          </ModuleLayout.Full>
+        </ModuleLayout.Layout>
+      </DetailPanel>
+    </PageAlertProvider>
+  );
+}
+
+const SAMPLE_HOVER_DEBOUNCE = 10;
+
+const SpanSummaryProjectAvatar = styled(ProjectAvatar)`
+  padding-right: ${space(1)};
+`;
+
+const HeaderContainer = styled('div')`
+  display: grid;
+  grid-template-rows: auto auto auto;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-rows: auto;
+    grid-template-columns: auto 1fr auto;
+  }
+`;
+
+const TitleContainer = styled('div')`
+  width: 100%;
+  position: relative;
+  height: 40px;
+`;
+
+const Title = styled('h4')`
+  position: absolute;
+  bottom: 0;
+  margin-bottom: 0;
+`;
+
+const MetricsRibbon = styled('div')`
+  display: flex;
+  flex-wrap: wrap;
+  gap: ${space(4)};
+`;

+ 19 - 1
static/app/views/performance/queues/messageSpanSamplesTable.tsx

@@ -1,4 +1,5 @@
 import type {ComponentProps} from 'react';
+import styled from '@emotion/styled';
 import type {Location} from 'history';
 
 import GridEditable, {
@@ -118,7 +119,16 @@ function renderBodyCell(
   location: Location,
   organization: Organization
 ) {
-  if (column.key === SpanIndexedField.ID) {
+  const key = column.key;
+  if (row[key] === undefined) {
+    return (
+      <AlignRight>
+        <NoValue>{' \u2014 '}</NoValue>
+      </AlignRight>
+    );
+  }
+
+  if (key === SpanIndexedField.ID) {
     return (
       <SpanIdCell
         projectSlug={row.project}
@@ -142,3 +152,11 @@ function renderBodyCell(
     unit: meta.units?.[column.key],
   });
 }
+
+const AlignRight = styled('span')`
+  text-align: right;
+`;
+
+const NoValue = styled('span')`
+  color: ${p => p.theme.gray300};
+`;

+ 84 - 0
static/app/views/performance/queues/queuesLandingPage.spec.tsx

@@ -0,0 +1,84 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary';
+
+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 QueuesLandingPage from 'sentry/views/performance/queues/queuesLandingPage';
+
+jest.mock('sentry/utils/useLocation');
+jest.mock('sentry/utils/usePageFilters');
+jest.mock('sentry/utils/useOrganization');
+jest.mock('sentry/utils/useProjects');
+
+describe('queuesLandingPage', () => {
+  const organization = OrganizationFixture({features: ['performance-queues-view']});
+
+  jest.mocked(usePageFilters).mockReturnValue({
+    isReady: true,
+    desyncedFilters: new Set(),
+    pinnedFilters: new Set(),
+    shouldPersist: true,
+    selection: {
+      datetime: {
+        period: '10d',
+        start: null,
+        end: null,
+        utc: false,
+      },
+      environments: [],
+      projects: [],
+    },
+  });
+
+  jest.mocked(useLocation).mockReturnValue({
+    pathname: '',
+    search: '',
+    query: {statsPeriod: '10d', project: '1'},
+    hash: '',
+    state: undefined,
+    action: 'PUSH',
+    key: '',
+  });
+
+  jest.mocked(useOrganization).mockReturnValue(organization);
+
+  jest.mocked(useProjects).mockReturnValue({
+    projects: [],
+    onSearch: jest.fn(),
+    placeholders: [],
+    fetching: false,
+    hasMore: null,
+    fetchError: null,
+    initiallyLoaded: false,
+  });
+
+  let eventsMock, eventsStatsMock;
+
+  beforeEach(() => {
+    eventsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      method: 'GET',
+      body: {data: []},
+    });
+
+    eventsStatsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events-stats/`,
+      method: 'GET',
+      body: {data: []},
+    });
+  });
+
+  it('renders', async () => {
+    render(<QueuesLandingPage />);
+    await screen.findByRole('table', {name: 'Queues'});
+    await waitForElementToBeRemoved(() => screen.queryAllByTestId('loading-indicator'));
+    screen.getByPlaceholderText('Search for events, users, tags, and more');
+    screen.getByText('Avg Latency');
+    screen.getByText('Published vs Processed');
+    expect(eventsStatsMock).toHaveBeenCalled();
+    expect(eventsMock).toHaveBeenCalled();
+  });
+});

+ 39 - 0
static/app/views/performance/queues/queuesLandingPage.tsx

@@ -1,4 +1,5 @@
 import {Fragment} from 'react';
+import styled from '@emotion/styled';
 
 import FeatureBadge from 'sentry/components/badge/featureBadge';
 import {Breadcrumbs} from 'sentry/components/breadcrumbs';
@@ -9,15 +10,23 @@ import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
 import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
 import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
+import SmartSearchBar from 'sentry/components/smartSearchBar';
 import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
 import useOrganization from 'sentry/utils/useOrganization';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {useOnboardingProject} from 'sentry/views/performance/browser/webVitals/utils/useOnboardingProject';
 import * as ModuleLayout from 'sentry/views/performance/moduleLayout';
 import {ModulePageProviders} from 'sentry/views/performance/modulePageProviders';
+import Onboarding from 'sentry/views/performance/onboarding';
+import {LatencyChart} from 'sentry/views/performance/queues/charts/latencyChart';
+import {ThroughputChart} from 'sentry/views/performance/queues/charts/throughputChart';
+import {QueuesTable} from 'sentry/views/performance/queues/queuesTable';
 import {MODULE_TITLE, RELEASE_LEVEL} from 'sentry/views/performance/queues/settings';
 
 function QueuesLandingPage() {
   const organization = useOrganization();
+  const onboardingProject = useOnboardingProject();
 
   return (
     <Fragment>
@@ -58,6 +67,30 @@ function QueuesLandingPage() {
                 <DatePageFilter />
               </PageFilterBar>
             </ModuleLayout.Full>
+
+            {onboardingProject && (
+              <Onboarding organization={organization} project={onboardingProject} />
+            )}
+
+            {!onboardingProject && (
+              <Fragment>
+                <ModuleLayout.Half>
+                  <LatencyChart />
+                </ModuleLayout.Half>
+
+                <ModuleLayout.Half>
+                  <ThroughputChart />
+                </ModuleLayout.Half>
+
+                <ModuleLayout.Full>
+                  <Flex>
+                    {/* TODO: Make search bar work */}
+                    <SmartSearchBar />
+                    <QueuesTable />
+                  </Flex>
+                </ModuleLayout.Full>
+              </Fragment>
+            )}
           </ModuleLayout.Layout>
         </Layout.Main>
       </Layout.Body>
@@ -77,3 +110,9 @@ function LandingPageWithProviders() {
   );
 }
 export default LandingPageWithProviders;
+
+const Flex = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(2)};
+`;

+ 12 - 7
static/app/views/performance/queues/queuesTable.spec.tsx

@@ -46,13 +46,18 @@ describe('queuesTable', () => {
   });
   it('renders', async () => {
     render(<QueuesTable />);
-    screen.getByText('Destination');
-    screen.getByText('Avg Time in Queue');
-    screen.getByText('Avg Processing Time');
-    screen.getByText('Error Rate');
-    screen.getByText('Published');
-    screen.getByText('Processed');
-    screen.getByText('Time Spent');
+    expect(screen.getByRole('table', {name: 'Queues'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Destination'})).toBeInTheDocument();
+    expect(
+      screen.getByRole('columnheader', {name: 'Avg Time in Queue'})
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('columnheader', {name: 'Avg Processing Time'})
+    ).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Error Rate'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Published'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Processed'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Time Spent'})).toBeInTheDocument();
     expect(eventsMock).toHaveBeenCalledWith(
       '/organizations/org-slug/events/',
       expect.objectContaining({

+ 2 - 0
static/app/views/performance/queues/queuesTable.tsx

@@ -161,7 +161,9 @@ function renderBodyCell(
 
 function DestinationCell({destination}: {destination: string}) {
   const organization = useOrganization();
+  const {query} = useLocation();
   const queryString = {
+    ...query,
     destination,
   };
   return (