Browse Source

replay(feat): Show setup instructions for Replay Network>Details req/resp bodies (#48231)

This implements the latest [figma
designs](https://www.figma.com/file/SVyNvm8p17xGfIC7r7l2YS/Specs%3A-Details-%2F-Network-Tab?node-id=268-34134&t=hiwdXg17vThX0y8o-0)
for the network req/resp display.

| | Details | Request | Response |
| --- | --- | --- | --- |
| Show Setup | ![not setup -
details](https://user-images.githubusercontent.com/187460/235796764-6bd1bfb7-e14e-42c8-a0c2-eb0b8cf30c1f.png)
| ![not setup -
request](https://user-images.githubusercontent.com/187460/235796768-aee79e0c-0b96-4ceb-ad1f-1395e85a3ef4.png)
| ![not setup -
response](https://user-images.githubusercontent.com/187460/235796761-f460af81-ff48-42f4-aa1f-fc90c7785f9d.png)
|
| With Data | ![setup -
details](https://user-images.githubusercontent.com/187460/235796770-3ef8d8ae-a4c1-4ab5-acfc-ed7bb263cc5d.png)
| ![setup -
request](https://user-images.githubusercontent.com/187460/235796771-6b9ea095-00e7-4f45-ace5-42209f95a925.png)
| ![setup -
response](https://user-images.githubusercontent.com/187460/235796774-5946f8fe-8255-4e07-a699-f9899b1999d4.png)
|



Other Cases:
| Non xhr/fetch | Skipped url | Alert to configure more headers |
| --- | --- | --- |
|
![SCR-20230502-nlyd](https://user-images.githubusercontent.com/187460/235797145-5467ef4b-0006-46ec-955e-6b6e02247d4f.png)
|
![SCR-20230502-nsew](https://user-images.githubusercontent.com/187460/235799388-64c4c084-a022-4983-9073-6388c483e4a0.png)
| <img width="623" alt="SCR-20230503-mwiv"
src="https://user-images.githubusercontent.com/187460/236053959-b8a3c3a3-5f31-426d-88c7-287d7a47851c.png">



Fixes https://github.com/getsentry/sentry/issues/47833
Ryan Albrecht 1 year ago
parent
commit
a390e6c24d

+ 13 - 0
static/app/utils/replays/replayReader.tsx

@@ -103,6 +103,8 @@ export default class ReplayReader {
   private networkSpans: ReplaySpan[];
   private memorySpans: MemorySpanType[];
 
+  private _isDetailsSetup: undefined | boolean;
+
   /**
    * @returns Duration of Replay (milliseonds)
    */
@@ -133,4 +135,15 @@ export default class ReplayReader {
   getMemorySpans = () => {
     return this.memorySpans;
   };
+
+  isNetworkDetailsSetup = () => {
+    if (this._isDetailsSetup === undefined) {
+      // TODO(replay): there must be a better way
+      const hasHeaders = span =>
+        Object.keys(span.data.request?.headers || {}).length ||
+        Object.keys(span.data.response?.headers || {}).length;
+      this._isDetailsSetup = this.networkSpans.some(hasHeaders);
+    }
+    return this._isDetailsSetup;
+  };
 }

+ 67 - 0
static/app/utils/useProjectSdkNeedsUpdate.spec.tsx

@@ -0,0 +1,67 @@
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import useProjectSdkNeedsUpdate from 'sentry/utils/useProjectSdkNeedsUpdate';
+import {useProjectSdkUpdates} from 'sentry/utils/useProjectSdkUpdates';
+
+jest.mock('sentry/utils/useProjectSdkUpdates');
+
+const mockUseProjectSdkUpdates = useProjectSdkUpdates as jest.MockedFunction<
+  typeof useProjectSdkUpdates
+>;
+
+function mockCurrentVersion(currentVersion: string) {
+  mockUseProjectSdkUpdates.mockReturnValue({
+    type: 'resolved',
+    data: {
+      projectId: TestStubs.Project().id,
+      sdkName: 'javascript',
+      sdkVersion: currentVersion,
+      suggestions: [],
+    },
+  });
+}
+describe('useProjectSdkNeedsUpdate', () => {
+  it('should return isFetching=true when sdk updates are not yet resolved', () => {
+    mockUseProjectSdkUpdates.mockReturnValue({
+      type: 'initial',
+    });
+
+    const {result} = reactHooks.renderHook(useProjectSdkNeedsUpdate, {
+      initialProps: {
+        minVersion: '1.0.0',
+        organization: TestStubs.Organization(),
+        projectId: TestStubs.Project().id,
+      },
+    });
+    expect(result.current.isFetching).toBeTruthy();
+    expect(result.current.needsUpdate).toBeUndefined();
+  });
+
+  it('should not need an update if the sdk version is above the min version', () => {
+    mockCurrentVersion('3.0.0');
+
+    const {result} = reactHooks.renderHook(useProjectSdkNeedsUpdate, {
+      initialProps: {
+        minVersion: '1.0.0',
+        organization: TestStubs.Organization(),
+        projectId: TestStubs.Project().id,
+      },
+    });
+    expect(result.current.isFetching).toBeFalsy();
+    expect(result.current.needsUpdate).toBeFalsy();
+  });
+
+  it('should be updated it the sdk version is too low', () => {
+    mockCurrentVersion('3.0.0');
+
+    const {result} = reactHooks.renderHook(useProjectSdkNeedsUpdate, {
+      initialProps: {
+        minVersion: '8.0.0',
+        organization: TestStubs.Organization(),
+        projectId: TestStubs.Project().id,
+      },
+    });
+    expect(result.current.isFetching).toBeFalsy();
+    expect(result.current.needsUpdate).toBeTruthy();
+  });
+});

+ 37 - 0
static/app/utils/useProjectSdkNeedsUpdate.tsx

@@ -0,0 +1,37 @@
+import {Organization} from 'sentry/types';
+import {useProjectSdkUpdates} from 'sentry/utils/useProjectSdkUpdates';
+import {semverCompare} from 'sentry/utils/versions';
+
+type Opts = {
+  minVersion: string;
+  organization: Organization;
+  projectId: string;
+};
+
+function useProjectSdkNeedsUpdate({minVersion, organization, projectId}: Opts):
+  | {
+      isFetching: true;
+      needsUpdate: undefined;
+    }
+  | {
+      isFetching: false;
+      needsUpdate: boolean;
+    } {
+  const sdkUpdates = useProjectSdkUpdates({
+    organization,
+    projectId,
+  });
+
+  if (sdkUpdates.type !== 'resolved') {
+    return {isFetching: true, needsUpdate: undefined};
+  }
+
+  if (!sdkUpdates.data?.sdkVersion) {
+    return {isFetching: true, needsUpdate: undefined};
+  }
+
+  const needsUpdate = semverCompare(sdkUpdates.data?.sdkVersion || '', minVersion) === -1;
+  return {isFetching: false, needsUpdate};
+}
+
+export default useProjectSdkNeedsUpdate;

+ 2 - 0
static/app/views/replays/detail/layout/focusArea.tsx

@@ -21,7 +21,9 @@ function FocusArea({}: Props) {
     case TabKey.network:
       return (
         <NetworkList
+          isNetworkDetailsSetup={Boolean(replay?.isNetworkDetailsSetup())}
           networkSpans={replay?.getNetworkSpans()}
+          projectId={replay?.getReplay()?.project_id}
           startTimestampMs={replay?.getReplay()?.started_at?.getTime() || 0}
         />
       );

+ 133 - 0
static/app/views/replays/detail/network/details/components.tsx

@@ -0,0 +1,133 @@
+import {Fragment, ReactNode, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconChevron} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+export const Indent = styled('div')`
+  padding-left: ${space(4)};
+`;
+
+const NotFoundText = styled('span')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const WarningText = styled('span')`
+  color: ${p => p.theme.errorText};
+`;
+
+export function Warning({warnings}: {warnings: undefined | string[]}) {
+  if (warnings?.includes('JSON_TRUNCATED') || warnings?.includes('TEXT_TRUNCATED')) {
+    return (
+      <WarningText>{t('Truncated (~~) due to exceeding 150k characters')}</WarningText>
+    );
+  }
+
+  if (warnings?.includes('INVALID_JSON')) {
+    return <WarningText>{t('Invalid JSON')}</WarningText>;
+  }
+
+  return null;
+}
+
+export function SizeTooltip({children}: {children: ReactNode}) {
+  return (
+    <Tooltip
+      title={t('It is possible the network transfer size is smaller due to compression.')}
+    >
+      {children}
+    </Tooltip>
+  );
+}
+
+export function keyValueTableOrNotFound(
+  data: undefined | Record<string, string>,
+  notFoundText: string
+) {
+  return data ? (
+    <StyledKeyValueTable noMargin>
+      {Object.entries(data).map(([key, value]) => (
+        <KeyValueTableRow key={key} keyName={key} value={<span>{value}</span>} />
+      ))}
+    </StyledKeyValueTable>
+  ) : (
+    <Indent>
+      <NotFoundText>{notFoundText}</NotFoundText>
+    </Indent>
+  );
+}
+
+const SectionTitle = styled('dt')``;
+
+const SectionTitleExtra = styled('span')`
+  flex-grow: 1;
+  text-align: right;
+  font-weight: normal;
+`;
+
+const SectionData = styled('dd')`
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+`;
+
+const ToggleButton = styled('button')`
+  background: ${p => p.theme.background};
+  border: 0;
+  color: ${p => p.theme.headingColor};
+  font-size: ${p => p.theme.fontSizeSmall};
+  font-weight: 600;
+  line-height: ${p => p.theme.text.lineHeightBody};
+
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  gap: ${space(1)};
+
+  padding: ${space(0.5)} ${space(1)};
+
+  :hover {
+    background: ${p => p.theme.backgroundSecondary};
+  }
+`;
+
+export function SectionItem({
+  children,
+  title,
+  titleExtra,
+}: {
+  children: ReactNode;
+  title: ReactNode;
+  titleExtra?: ReactNode;
+}) {
+  const [isOpen, setIsOpen] = useState(true);
+
+  return (
+    <Fragment>
+      <SectionTitle>
+        <ToggleButton aria-label={t('toggle section')} onClick={() => setIsOpen(!isOpen)}>
+          <IconChevron direction={isOpen ? 'up' : 'down'} size="xs" />
+          {title}
+          {titleExtra ? <SectionTitleExtra>{titleExtra}</SectionTitleExtra> : null}
+        </ToggleButton>
+      </SectionTitle>
+      <SectionData>{isOpen ? children : null}</SectionData>
+    </Fragment>
+  );
+}
+
+const StyledKeyValueTable = styled(KeyValueTable)`
+  & > dt {
+    font-size: ${p => p.theme.fontSizeSmall};
+    padding-left: ${space(4)};
+  }
+  & > dd {
+    ${p => p.theme.overflowEllipsis};
+    font-size: ${p => p.theme.fontSizeSmall};
+    display: flex;
+    justify-content: flex-end;
+  }
+`;

+ 419 - 0
static/app/views/replays/detail/network/details/content.spec.tsx

@@ -0,0 +1,419 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import useProjectSdkNeedsUpdate from 'sentry/utils/useProjectSdkNeedsUpdate';
+import NetworkDetailsContent from 'sentry/views/replays/detail/network/details/content';
+import type {TabKey} from 'sentry/views/replays/detail/network/details/tabs';
+
+jest.mock('sentry/utils/useProjectSdkNeedsUpdate');
+
+const mockUseProjectSdkNeedsUpdate = useProjectSdkNeedsUpdate as jest.MockedFunction<
+  typeof useProjectSdkNeedsUpdate
+>;
+
+function mockNeedsUpdate(needsUpdate: boolean) {
+  mockUseProjectSdkNeedsUpdate.mockReturnValue({isFetching: false, needsUpdate});
+}
+
+const mockItems = {
+  img: TestStubs.ReplaySpanPayload({
+    op: 'resource.img',
+    description: '/static/img/logo.png',
+    data: {
+      method: 'GET',
+      statusCode: 200,
+    },
+  }),
+  fetchNoDataObj: TestStubs.ReplaySpanPayload({
+    op: 'resource.fetch',
+    description: '/api/0/issues/1234',
+  }),
+  fetchUrlSkipped: TestStubs.ReplaySpanPayload({
+    op: 'resource.fetch',
+    description: '/api/0/issues/1234',
+    data: {
+      method: 'GET',
+      statusCode: 200,
+      request: {_meta: {warnings: ['URL_SKIPPED']}, headers: {}},
+      response: {_meta: {warnings: ['URL_SKIPPED']}, headers: {}},
+    },
+  }),
+  fetchBodySkipped: TestStubs.ReplaySpanPayload({
+    op: 'resource.fetch',
+    description: '/api/0/issues/1234',
+    data: {
+      method: 'GET',
+      statusCode: 200,
+      request: {
+        _meta: {warnings: ['BODY_SKIPPED']},
+        headers: {accept: 'application/json'},
+      },
+      response: {
+        _meta: {warnings: ['BODY_SKIPPED']},
+        headers: {'content-type': 'application/json'},
+      },
+    },
+  }),
+  fetchWithHeaders: TestStubs.ReplaySpanPayload({
+    op: 'resource.fetch',
+    description: '/api/0/issues/1234',
+    data: {
+      method: 'GET',
+      statusCode: 200,
+      request: {
+        _meta: {},
+        headers: {accept: 'application/json'},
+      },
+      response: {
+        _meta: {},
+        headers: {'content-type': 'application/json'},
+      },
+    },
+  }),
+  fetchWithRespBody: TestStubs.ReplaySpanPayload({
+    op: 'resource.fetch',
+    description: '/api/0/issues/1234',
+    data: {
+      method: 'GET',
+      statusCode: 200,
+      request: {
+        _meta: {},
+        headers: {accept: 'application/json'},
+      },
+      response: {
+        _meta: {},
+        headers: {'content-type': 'application/json'},
+        body: {success: true},
+      },
+    },
+  }),
+};
+
+function basicSectionProps() {
+  return {
+    onScrollToRow: () => {},
+    projectId: '',
+    startTimestampMs: new Date('2023-12-24').getTime(),
+  };
+}
+
+function queryScreenState() {
+  return {
+    dataSectionHeaders: screen
+      .queryAllByLabelText('toggle section')
+      .map(elem => elem.textContent),
+    isShowingSetup: Boolean(screen.queryByTestId('network-setup-steps')),
+    isShowingUnsupported: Boolean(screen.queryByTestId('network-op-unsupported')),
+  };
+}
+
+describe('NetworkDetailsContent', () => {
+  mockNeedsUpdate(false);
+
+  describe('Details Tab', () => {
+    const visibleTab = 'details' as TabKey;
+
+    describe('Unsupported Operation', () => {
+      it.each([
+        {isSetup: false, itemName: 'img'},
+        {isSetup: true, itemName: 'img'},
+      ])(
+        'should render the `general` & `unsupported` sections when the span is not FETCH or XHR and isSetup=$isSetup. [$itemName]',
+        ({isSetup}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems.img}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['General'],
+            isShowingSetup: false,
+            isShowingUnsupported: true,
+          });
+        }
+      );
+    });
+
+    describe('Supported Operation', () => {
+      it.each([
+        {isSetup: false, itemName: 'fetchNoDataObj'},
+        {isSetup: false, itemName: 'fetchUrlSkipped'},
+        {isSetup: false, itemName: 'fetchBodySkipped'},
+        {isSetup: false, itemName: 'fetchWithHeaders'},
+        {isSetup: false, itemName: 'fetchWithRespBody'},
+      ])(
+        'should render the `general` & `setup` sections when isSetup=false, no matter the item. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['General'],
+            isShowingSetup: true,
+            isShowingUnsupported: false,
+          });
+        }
+      );
+
+      it.each([
+        {isSetup: true, itemName: 'fetchNoDataObj'},
+        {isSetup: true, itemName: 'fetchUrlSkipped'},
+      ])(
+        'should render the `general` & `setup` sections when the item has no data. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['General'],
+            isShowingSetup: true,
+            isShowingUnsupported: false,
+          });
+        }
+      );
+
+      it.each([
+        {isSetup: true, itemName: 'fetchBodySkipped'},
+        {isSetup: true, itemName: 'fetchWithHeaders'},
+        {isSetup: true, itemName: 'fetchWithRespBody'},
+      ])(
+        'should render the `general` & two `headers` sections, and always the setup section, when things are setup and the item has some data. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['General', 'Request Headers', 'Response Headers'],
+            isShowingUnsupported: false,
+            isShowingSetup: true,
+          });
+        }
+      );
+    });
+  });
+
+  describe('Request Tab', () => {
+    const visibleTab = 'request' as TabKey;
+
+    describe('Unsupported Operation', () => {
+      it.each([
+        {isSetup: false, itemName: 'img'},
+        {isSetup: true, itemName: 'img'},
+      ])(
+        'should render the `query params` & `unsupported` sections when the span is not FETCH or XHR and isSetup=$isSetup. [$itemName]',
+        ({isSetup}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems.img}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['Query String Parameters'],
+            isShowingSetup: false,
+            isShowingUnsupported: true,
+          });
+        }
+      );
+    });
+
+    describe('Supported Operation', () => {
+      it.each([
+        {isSetup: false, itemName: 'fetchNoDataObj'},
+        {isSetup: false, itemName: 'fetchUrlSkipped'},
+        {isSetup: false, itemName: 'fetchBodySkipped'},
+        {isSetup: false, itemName: 'fetchWithHeaders'},
+        {isSetup: false, itemName: 'fetchWithRespBody'},
+      ])(
+        'should render the `query params` & `setup` sections when isSetup is false, no matter the item. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['Query String Parameters'],
+            isShowingSetup: true,
+            isShowingUnsupported: false,
+          });
+        }
+      );
+
+      it.each([
+        {isSetup: true, itemName: 'fetchNoDataObj'},
+        {isSetup: true, itemName: 'fetchUrlSkipped'},
+        {isSetup: true, itemName: 'fetchBodySkipped'},
+        {isSetup: true, itemName: 'fetchWithHeaders'},
+      ])(
+        'should render the `query params` & `setup` sections when the item has no data. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['Query String Parameters'],
+            isShowingSetup: true,
+            isShowingUnsupported: false,
+          });
+        }
+      );
+
+      it.each([{isSetup: true, itemName: 'fetchWithRespBody'}])(
+        'should render the `query params` & `request payload` sections when things are setup and the item has some data. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['Query String Parameters', 'Request PayloadSize: 0 B'],
+            isShowingUnsupported: false,
+            isShowingSetup: false,
+          });
+        }
+      );
+    });
+  });
+
+  describe('Response Tab', () => {
+    const visibleTab = 'response' as TabKey;
+
+    describe('Unsupported Operation', () => {
+      it.each([
+        {isSetup: false, itemName: 'img'},
+        {isSetup: true, itemName: 'img'},
+      ])(
+        'should render the `unsupported` section when the span is not FETCH or XHR and isSetup=$isSetup. [$itemName]',
+        ({isSetup}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems.img}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: [],
+            isShowingSetup: false,
+            isShowingUnsupported: true,
+          });
+        }
+      );
+    });
+
+    describe('Supported Operation', () => {
+      it.each([
+        {isSetup: false, itemName: 'fetchNoDataObj'},
+        {isSetup: false, itemName: 'fetchUrlSkipped'},
+        {isSetup: false, itemName: 'fetchBodySkipped'},
+        {isSetup: false, itemName: 'fetchWithHeaders'},
+        {isSetup: false, itemName: 'fetchWithRespBody'},
+      ])(
+        'should render the `setup` section when isSetup is false, no matter the item. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: [],
+            isShowingSetup: true,
+            isShowingUnsupported: false,
+          });
+        }
+      );
+
+      it.each([
+        {isSetup: true, itemName: 'fetchNoDataObj'},
+        {isSetup: true, itemName: 'fetchUrlSkipped'},
+        {isSetup: true, itemName: 'fetchBodySkipped'},
+        {isSetup: true, itemName: 'fetchWithHeaders'},
+      ])(
+        'should render the `setup` section when the item has no data. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: [],
+            isShowingSetup: true,
+            isShowingUnsupported: false,
+          });
+        }
+      );
+
+      it.each([{isSetup: true, itemName: 'fetchWithRespBody'}])(
+        'should render the `response body` section when things are setup and the item has some data. [$itemName]',
+        ({isSetup, itemName}) => {
+          render(
+            <NetworkDetailsContent
+              {...basicSectionProps()}
+              isSetup={isSetup}
+              item={mockItems[itemName]}
+              visibleTab={visibleTab}
+            />
+          );
+
+          expect(queryScreenState()).toStrictEqual({
+            dataSectionHeaders: ['Response BodySize: 0 B'],
+            isShowingUnsupported: false,
+            isShowingSetup: false,
+          });
+        }
+      );
+    });
+  });
+});

+ 79 - 0
static/app/views/replays/detail/network/details/content.tsx

@@ -0,0 +1,79 @@
+import styled from '@emotion/styled';
+
+import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
+import getOutputType, {
+  Output,
+} from 'sentry/views/replays/detail/network/details/getOutputType';
+import {
+  Setup,
+  UnsupportedOp,
+} from 'sentry/views/replays/detail/network/details/onboarding';
+import type {SectionProps} from 'sentry/views/replays/detail/network/details/sections';
+import {
+  GeneralSection,
+  QueryParamsSection,
+  RequestHeadersSection,
+  RequestPayloadSection,
+  ResponseHeadersSection,
+  ResponsePayloadSection,
+} from 'sentry/views/replays/detail/network/details/sections';
+
+type Props = Parameters<typeof getOutputType>[0] & SectionProps;
+
+export default function NetworkDetailsContent(props: Props) {
+  const {visibleTab} = props;
+
+  const output = getOutputType(props);
+
+  switch (visibleTab) {
+    case 'request':
+      return (
+        <OverflowFluidHeight>
+          <SectionList>
+            <QueryParamsSection {...props} />
+            {output === Output.DATA && <RequestPayloadSection {...props} />}
+          </SectionList>
+          {[Output.SETUP, Output.URL_SKIPPED, Output.BODY_SKIPPED].includes(output) && (
+            <Setup showSnippet={output} {...props} />
+          )}
+          {output === Output.UNSUPPORTED && <UnsupportedOp type="bodies" />}
+        </OverflowFluidHeight>
+      );
+    case 'response':
+      return (
+        <OverflowFluidHeight>
+          {output === Output.DATA && (
+            <SectionList>
+              <ResponsePayloadSection {...props} />
+            </SectionList>
+          )}
+          {[Output.SETUP, Output.URL_SKIPPED, Output.BODY_SKIPPED].includes(output) && (
+            <Setup showSnippet={output} {...props} />
+          )}
+          {output === Output.UNSUPPORTED && <UnsupportedOp type="bodies" />}
+        </OverflowFluidHeight>
+      );
+    case 'details':
+    default:
+      return (
+        <OverflowFluidHeight>
+          <SectionList>
+            <GeneralSection {...props} />
+            {output === Output.DATA && <RequestHeadersSection {...props} />}
+            {output === Output.DATA && <ResponseHeadersSection {...props} />}
+          </SectionList>
+          {[Output.SETUP, Output.URL_SKIPPED, Output.DATA].includes(output) && (
+            <Setup showSnippet={output} {...props} />
+          )}
+          {output === Output.UNSUPPORTED && <UnsupportedOp type="headers" />}
+        </OverflowFluidHeight>
+      );
+  }
+}
+
+const OverflowFluidHeight = styled(FluidHeight)`
+  overflow: auto;
+`;
+const SectionList = styled('dl')`
+  margin: 0;
+`;

+ 60 - 0
static/app/views/replays/detail/network/details/getOutputType.tsx

@@ -0,0 +1,60 @@
+import type {SectionProps} from 'sentry/views/replays/detail/network/details/sections';
+import type {TabKey} from 'sentry/views/replays/detail/network/details/tabs';
+
+export enum Output {
+  SETUP = 'setup',
+  UNSUPPORTED = 'unsupported',
+  URL_SKIPPED = 'url_skipped',
+  BODY_SKIPPED = 'body_skipped',
+  DATA = 'data',
+}
+
+type Args = {
+  isSetup: boolean;
+  item: SectionProps['item'];
+  visibleTab: TabKey;
+};
+
+export default function getOutputType({isSetup, item, visibleTab}: Args): Output {
+  const isSupportedOp = ['resource.fetch', 'resource.xhr'].includes(item.op);
+  if (!isSupportedOp) {
+    return Output.UNSUPPORTED;
+  }
+
+  if (!isSetup) {
+    return Output.SETUP;
+  }
+
+  const request = item.data?.request ?? {};
+  const response = item.data?.response ?? {};
+
+  const hasHeaders =
+    Object.keys(request.headers || {}).length ||
+    Object.keys(response.headers || {}).length;
+  if (hasHeaders && visibleTab === 'details') {
+    return Output.DATA;
+  }
+
+  const hasBody = request.body || response.body;
+  if (hasBody && ['request', 'response'].includes(visibleTab)) {
+    return Output.DATA;
+  }
+
+  const reqWarnings = request._meta?.warnings ?? ['URL_SKIPPED'];
+  const respWarnings = response._meta?.warnings ?? ['URL_SKIPPED'];
+  const isReqUrlSkipped = reqWarnings?.includes('URL_SKIPPED');
+  const isRespUrlSkipped = respWarnings?.includes('URL_SKIPPED');
+  if (isReqUrlSkipped || isRespUrlSkipped) {
+    return Output.URL_SKIPPED;
+  }
+
+  if (['request', 'response'].includes(visibleTab)) {
+    const isReqBodySkipped = reqWarnings?.includes('BODY_SKIPPED');
+    const isRespBodySkipped = respWarnings?.includes('BODY_SKIPPED');
+    if (isReqBodySkipped || isRespBodySkipped) {
+      return Output.BODY_SKIPPED;
+    }
+  }
+
+  return Output.DATA;
+}

+ 19 - 15
static/app/views/replays/detail/network/networkDetails.tsx → static/app/views/replays/detail/network/details/index.tsx

@@ -8,33 +8,36 @@ import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {useResizableDrawer} from 'sentry/utils/useResizableDrawer';
 import useUrlParams from 'sentry/utils/useUrlParams';
-import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
 import SplitDivider from 'sentry/views/replays/detail/layout/splitDivider';
-import NetworkDetailsContent from 'sentry/views/replays/detail/network/networkDetailsContent';
+import NetworkDetailsContent from 'sentry/views/replays/detail/network/details/content';
 import NetworkDetailsTabs, {
   TabKey,
-} from 'sentry/views/replays/detail/network/networkDetailsTabs';
+} from 'sentry/views/replays/detail/network/details/tabs';
 import type {NetworkSpan} from 'sentry/views/replays/types';
 
 type Props = {
+  isSetup: boolean;
   item: null | NetworkSpan;
   onClose: () => void;
   onScrollToRow: () => void;
+  projectId: undefined | string;
   startTimestampMs: number;
 } & Omit<ReturnType<typeof useResizableDrawer>, 'size'>;
 
-function NetworkRequestDetails({
+function NetworkDetails({
   isHeld,
+  isSetup,
   item,
   onClose,
   onDoubleClick,
   onMouseDown,
   onScrollToRow,
+  projectId,
   startTimestampMs,
 }: Props) {
   const {getParamValue: getDetailTab} = useUrlParams('n_detail_tab', 'details');
 
-  if (!item) {
+  if (!item || !projectId) {
     return null;
   }
 
@@ -63,14 +66,15 @@ function NetworkRequestDetails({
           />
         </CloseButtonWrapper>
       </StyledStacked>
-      <FluidHeight>
-        <NetworkDetailsContent
-          visibleTab={visibleTab}
-          item={item}
-          onScrollToRow={onScrollToRow}
-          startTimestampMs={startTimestampMs}
-        />
-      </FluidHeight>
+
+      <NetworkDetailsContent
+        isSetup={isSetup}
+        item={item}
+        onScrollToRow={onScrollToRow}
+        projectId={projectId}
+        startTimestampMs={startTimestampMs}
+        visibleTab={visibleTab}
+      />
     </Fragment>
   );
 }
@@ -97,7 +101,7 @@ const StyledNetworkDetailsTabs = styled(NetworkDetailsTabs)`
     padding-left: ${space(2)};
   }
   & > li:last-child {
-    padding-right: 0;
+    padding-right: ${space(1)};
   }
 
   & > li > a {
@@ -124,4 +128,4 @@ const StyledSplitDivider = styled(SplitDivider)<{isHeld: boolean}>`
   }
 `;
 
-export default NetworkRequestDetails;
+export default NetworkDetails;

+ 141 - 0
static/app/views/replays/detail/network/details/onboarding.spec.tsx

@@ -0,0 +1,141 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import useProjectSdkNeedsUpdate from 'sentry/utils/useProjectSdkNeedsUpdate';
+import {Output} from 'sentry/views/replays/detail/network/details/getOutputType';
+
+jest.mock('sentry/utils/useProjectSdkNeedsUpdate');
+
+const mockUseProjectSdkNeedsUpdate = useProjectSdkNeedsUpdate as jest.MockedFunction<
+  typeof useProjectSdkNeedsUpdate
+>;
+
+import {Setup} from 'sentry/views/replays/detail/network/details/onboarding';
+
+const MOCK_ITEM = TestStubs.ReplaySpanPayload({
+  op: 'resource.fetch',
+  description: '/api/0/issues/1234',
+});
+
+describe('Setup', () => {
+  mockUseProjectSdkNeedsUpdate.mockReturnValue({isFetching: false, needsUpdate: false});
+
+  describe('Setup is not complete', () => {
+    it('should render the full snippet when no setup is done yet', () => {
+      const {container} = render(
+        <Setup
+          item={MOCK_ITEM}
+          projectId="0"
+          showSnippet={Output.SETUP}
+          visibleTab="details"
+        />
+      );
+
+      expect(
+        screen.getByText('Capture Request and Response Headers and Payloads')
+      ).toBeInTheDocument();
+
+      expect(container.querySelector('code')).toHaveTextContent(
+        `networkRequestHeaders: ['X-Custom-Header'],`
+      );
+    });
+  });
+
+  describe('Url is skipped', () => {
+    it('should render a note on the Details tab to allow this url', () => {
+      const {container} = render(
+        <Setup
+          item={MOCK_ITEM}
+          projectId="0"
+          showSnippet={Output.URL_SKIPPED}
+          visibleTab="details"
+        />
+      );
+
+      expect(
+        screen.getByText('Capture Request and Response Headers')
+      ).toBeInTheDocument();
+
+      expect(container.querySelector('code')).toHaveTextContent(
+        `networkRequestHeaders: ['X-Custom-Header'],`
+      );
+
+      expect(
+        screen.getByText(
+          textWithMarkupMatcher(
+            'Add /api/0/issues/1234 to your networkDetailAllowUrls list to start capturing data.'
+          )
+        )
+      ).toBeInTheDocument();
+    });
+
+    it('should render a note on the Requst & Response tabs to allow this url and enable capturing bodies', () => {
+      render(
+        <Setup
+          item={MOCK_ITEM}
+          projectId="0"
+          showSnippet={Output.URL_SKIPPED}
+          visibleTab="request"
+        />
+      );
+
+      expect(
+        screen.getByText('Capture Request and Response Payloads')
+      ).toBeInTheDocument();
+
+      expect(
+        screen.getByText(
+          textWithMarkupMatcher(
+            'Add /api/0/issues/1234 to your networkDetailAllowUrls list to start capturing data.'
+          )
+        )
+      ).toBeInTheDocument();
+    });
+  });
+
+  describe('Body is skipped', () => {
+    it('should render a note on the Requst & Response tabs to enable capturing bodies', () => {
+      render(
+        <Setup
+          item={MOCK_ITEM}
+          projectId="0"
+          showSnippet={Output.BODY_SKIPPED}
+          visibleTab="request"
+        />
+      );
+
+      expect(
+        screen.getByText('Capture Request and Response Payloads')
+      ).toBeInTheDocument();
+
+      expect(
+        screen.getByText(
+          textWithMarkupMatcher(
+            'Enable networkCaptureBodies: true to capture both Request and Response payloads.'
+          )
+        )
+      ).toBeInTheDocument();
+    });
+  });
+
+  describe('Showing the data', () => {
+    it('should render a short message reminding you to configure custom headers', () => {
+      render(
+        <Setup
+          item={MOCK_ITEM}
+          projectId="0"
+          showSnippet={Output.DATA}
+          visibleTab="details"
+        />
+      );
+
+      expect(
+        screen.getByText(
+          textWithMarkupMatcher(
+            'You can capture more customer headers by adding them to the networkRequestHeaders and networkResponseHeaders lists in your SDK config.'
+          )
+        )
+      ).toBeInTheDocument();
+    });
+  });
+});

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