Просмотр исходного кода

feat(issues): Add a trace timeline to issues (#64129)

Scott Cooper 1 год назад
Родитель
Сommit
6558cb7868

+ 3 - 2
static/app/views/issueDetails/groupEventHeader.tsx

@@ -3,10 +3,10 @@ import styled from '@emotion/styled';
 import {DataSection} from 'sentry/components/events/styles';
 import GlobalAppStoreConnectUpdateAlert from 'sentry/components/globalAppStoreConnectUpdateAlert';
 import {space} from 'sentry/styles/space';
-import type {Group, Project} from 'sentry/types';
-import type {Event} from 'sentry/types/event';
+import type {Event, Group, Project} from 'sentry/types';
 import useOrganization from 'sentry/utils/useOrganization';
 import {GroupEventCarousel} from 'sentry/views/issueDetails/groupEventCarousel';
+import {TraceTimeline} from 'sentry/views/issueDetails/traceTimeline/traceTimeline';
 
 type GroupEventHeaderProps = {
   event: Event;
@@ -24,6 +24,7 @@ function GroupEventHeader({event, group, project}: GroupEventHeaderProps) {
         project={project}
         organization={organization}
       />
+      <TraceTimeline event={event} />
     </DataSection>
   );
 }

+ 85 - 0
static/app/views/issueDetails/traceTimeline/traceTimeline.spec.tsx

@@ -0,0 +1,85 @@
+import {EventFixture} from 'sentry-fixture/event';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+import {UserFixture} from 'sentry-fixture/user';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import ConfigStore from 'sentry/stores/configStore';
+import ProjectsStore from 'sentry/stores/projectsStore';
+
+import {TraceTimeline} from './traceTimeline';
+import type {TraceEventResponse} from './useTraceTimelineEvents';
+
+describe('TraceTimeline', () => {
+  const organization = OrganizationFixture({features: ['issues-trace-timeline']});
+  const event = EventFixture({
+    contexts: {
+      trace: {
+        trace_id: '123',
+      },
+    },
+  });
+  const project = ProjectFixture();
+
+  const issuePlatformBody: TraceEventResponse = {
+    data: [
+      {
+        timestamp: '2024-01-24T09:09:03+00:00',
+        'issue.id': 1000,
+        project: project.slug,
+        'project.name': project.name,
+        title: 'Slow DB Query',
+        id: 'abc',
+        issue: 'SENTRY-ABC1',
+      },
+    ],
+    meta: {fields: {}, units: {}},
+  };
+  const discoverBody: TraceEventResponse = {
+    data: [
+      {
+        timestamp: '2024-01-23T22:11:42+00:00',
+        'issue.id': 4909507143,
+        project: project.slug,
+        'project.name': project.name,
+        title: 'AttributeError: Something Failed',
+        id: event.id,
+        issue: 'SENTRY-2EYS',
+      },
+    ],
+    meta: {fields: {}, units: {}},
+  };
+
+  beforeEach(() => {
+    // Can be removed with issueDetailsNewExperienceQ42023
+    ProjectsStore.loadInitialData([project]);
+    ConfigStore.set(
+      'user',
+      UserFixture({
+        options: {
+          ...UserFixture().options,
+          issueDetailsNewExperienceQ42023: true,
+        },
+      })
+    );
+  });
+
+  it('renders items and highlights the current event', async () => {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: issuePlatformBody,
+      match: [MockApiClient.matchQuery({dataset: 'issuePlatform'})],
+    });
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: discoverBody,
+      match: [MockApiClient.matchQuery({dataset: 'discover'})],
+    });
+    render(<TraceTimeline event={event} />, {organization});
+    expect(await screen.findByLabelText('Current Event')).toBeInTheDocument();
+
+    await userEvent.hover(screen.getByLabelText('Current Event'));
+    expect(await screen.findByText('You are here')).toBeInTheDocument();
+  });
+});

+ 89 - 0
static/app/views/issueDetails/traceTimeline/traceTimeline.tsx

@@ -0,0 +1,89 @@
+import {useRef} from 'react';
+import styled from '@emotion/styled';
+
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import Placeholder from 'sentry/components/placeholder';
+import type {Event} from 'sentry/types';
+import {useDimensions} from 'sentry/utils/useDimensions';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useUser} from 'sentry/utils/useUser';
+import {hasTraceTimelineFeature} from 'sentry/views/issueDetails/traceTimeline/utils';
+
+import {TraceTimelineEvents} from './traceTimelineEvents';
+import {useTraceTimelineEvents} from './useTraceTimelineEvents';
+
+interface TraceTimelineProps {
+  event: Event;
+}
+
+export function TraceTimeline({event}: TraceTimelineProps) {
+  const user = useUser();
+  const organization = useOrganization({allowNull: true});
+  const timelineRef = useRef<HTMLDivElement>(null);
+  const {width} = useDimensions({elementRef: timelineRef});
+  const hasFeature = hasTraceTimelineFeature(organization, user);
+  const {isError, isLoading} = useTraceTimelineEvents({event}, hasFeature);
+
+  if (!hasFeature) {
+    return null;
+  }
+
+  if (isError) {
+    // display placeholder to reduce layout shift
+    return <div style={{height: 20}} />;
+  }
+
+  return (
+    <ErrorBoundary mini>
+      <div>
+        <Stacked ref={timelineRef}>
+          {isLoading ? (
+            <Placeholder height="45px" />
+          ) : (
+            <TimelineEventsContainer>
+              <TimelineOutline />
+              {/* Sets a min width of 200 for testing */}
+              <TraceTimelineEvents event={event} width={Math.max(width, 200)} />
+            </TimelineEventsContainer>
+          )}
+        </Stacked>
+      </div>
+    </ErrorBoundary>
+  );
+}
+
+/**
+ * Displays the container the dots appear inside of
+ */
+const TimelineOutline = styled('div')`
+  position: absolute;
+  left: 0;
+  top: 5px;
+  width: 100%;
+  height: 6px;
+  border: 1px solid ${p => p.theme.innerBorder};
+  border-radius: ${p => p.theme.borderRadius};
+`;
+
+/**
+ * Render all child elements directly on top of each other.
+ *
+ * This implementation does not remove the stack of elements from the document
+ * flow, so width/height is reserved.
+ *
+ * An alternative would be to use `position:absolute;` in which case the size
+ * would not be part of document flow and other elements could render behind.
+ */
+const Stacked = styled('div')`
+  display: grid;
+  grid-template: 1fr / 1fr;
+  > * {
+    grid-area: 1 / 1;
+  }
+`;
+
+const TimelineEventsContainer = styled('div')`
+  position: relative;
+  height: 45px;
+  padding-top: 10px;
+`;

+ 208 - 0
static/app/views/issueDetails/traceTimeline/traceTimelineEvents.tsx

@@ -0,0 +1,208 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import DateTime from 'sentry/components/dateTime';
+import {Tooltip} from 'sentry/components/tooltip';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Event} from 'sentry/types';
+import {TraceTimelineTooltip} from 'sentry/views/issueDetails/traceTimeline/traceTimelineTooltip';
+
+import type {TimelineEvent} from './useTraceTimelineEvents';
+import {useTraceTimelineEvents} from './useTraceTimelineEvents';
+import {getEventsByColumn} from './utils';
+
+// Adjusting this will change the number of tooltip groups
+const markerWidth = 24;
+// Adjusting subwidth changes how many dots to render
+const subWidth = 2;
+
+interface TraceTimelineEventsProps {
+  event: Event;
+  width: number;
+}
+
+export function TraceTimelineEvents({event, width}: TraceTimelineEventsProps) {
+  const {startTimestamp, endTimestamp, data} = useTraceTimelineEvents({event});
+  let durationMs = endTimestamp - startTimestamp;
+  const paddedStartTime = startTimestamp - 200;
+  let paddedEndTime = endTimestamp + 100;
+  // Will need to figure out padding
+  if (durationMs === 0) {
+    durationMs = 1000;
+    // If the duration is 0, we need to pad the end time
+    paddedEndTime = startTimestamp + 1000;
+  }
+
+  const totalColumns = Math.floor(width / markerWidth);
+  const eventsByColumn = getEventsByColumn(
+    durationMs,
+    data,
+    totalColumns,
+    paddedStartTime
+  );
+  const columnSize = width / totalColumns;
+
+  return (
+    <Fragment>
+      <TimelineColumns totalColumns={totalColumns}>
+        {Array.from(eventsByColumn.entries()).map(([column, colEvents]) => {
+          // Calculate the timestamp range that this column represents
+          const columnStartTimestamp =
+            (durationMs / totalColumns) * (column - 1) + paddedStartTime;
+          const columnEndTimestamp =
+            (durationMs / totalColumns) * column + paddedStartTime;
+          return (
+            <EventColumn
+              key={column}
+              style={{gridColumn: Math.floor(column), width: columnSize}}
+            >
+              <NodeGroup
+                event={event}
+                colEvents={colEvents}
+                columnSize={columnSize}
+                timeRange={[columnStartTimestamp, columnEndTimestamp]}
+                currentEventId={event.id}
+              />
+            </EventColumn>
+          );
+        })}
+      </TimelineColumns>
+      <TimestampColumns>
+        <TimestampItem style={{textAlign: 'left'}}>
+          <DateTime date={paddedStartTime} timeOnly />
+        </TimestampItem>
+        <TimestampItem style={{textAlign: 'center'}}>
+          <DateTime date={paddedStartTime + Math.floor(durationMs / 2)} timeOnly />
+        </TimestampItem>
+        <TimestampItem style={{textAlign: 'right'}}>
+          <DateTime date={paddedEndTime} timeOnly />
+        </TimestampItem>
+      </TimestampColumns>
+    </Fragment>
+  );
+}
+
+/**
+ * Use grid to create columns that we can place child nodes into.
+ * Leveraging grid for alignment means we don't need to calculate percent offset
+ * nor use position:absolute to lay out items.
+ *
+ * <Columns>
+ *   <Col>...</Col>
+ *   <Col>...</Col>
+ * </Columns>
+ */
+const TimelineColumns = styled('ul')<{totalColumns: number}>`
+  /* Reset defaults for <ul> */
+  list-style: none;
+  margin: 0;
+  padding: 0;
+
+  /* Layout of the lines */
+  display: grid;
+  grid-template-columns: repeat(${p => p.totalColumns}, 1fr);
+  margin-top: -1px;
+`;
+
+const TimestampColumns = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  margin-top: ${space(1)};
+`;
+
+const TimestampItem = styled('div')`
+  place-items: stretch;
+  display: grid;
+  align-items: center;
+  position: relative;
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+function NodeGroup({
+  event,
+  timeRange,
+  colEvents,
+  columnSize,
+  currentEventId,
+}: {
+  colEvents: TimelineEvent[];
+  columnSize: number;
+  currentEventId: string;
+  event: Event;
+  timeRange: [number, number];
+}) {
+  const totalSubColumns = Math.floor(columnSize / subWidth);
+  const durationMs = timeRange[1] - timeRange[0];
+  const eventsByColumn = getEventsByColumn(
+    durationMs,
+    colEvents,
+    totalSubColumns,
+    timeRange[1]
+  );
+
+  return (
+    <Tooltip
+      title={<TraceTimelineTooltip event={event} timelineEvents={colEvents} />}
+      overlayStyle={{
+        padding: `0 !important`,
+        maxWidth: '250px !important',
+        width: '250px',
+      }}
+      offset={10}
+      position="bottom"
+      isHoverable
+      skipWrapper
+    >
+      <TimelineColumns totalColumns={totalSubColumns}>
+        {Array.from(eventsByColumn.entries()).map(([column, groupEvents]) => (
+          <EventColumn key={column} style={{gridColumn: Math.floor(column)}}>
+            {groupEvents.map(groupEvent => (
+              // TODO: use sentry colors and add the other styles
+              <IconNode
+                key={groupEvent.id}
+                aria-label={
+                  groupEvent.id === currentEventId ? t('Current Event') : undefined
+                }
+                style={
+                  groupEvent.id === currentEventId
+                    ? {
+                        backgroundColor: 'rgb(181, 19, 7, 1)',
+                        outline: '1px solid rgb(181, 19, 7, 0.5)',
+                        outlineOffset: '3px',
+                      }
+                    : undefined
+                }
+              />
+            ))}
+          </EventColumn>
+        ))}
+      </TimelineColumns>
+    </Tooltip>
+  );
+}
+
+const EventColumn = styled('li')`
+  place-items: stretch;
+  display: grid;
+  align-items: center;
+  position: relative;
+
+  &:hover {
+    z-index: ${p => p.theme.zIndex.initial};
+  }
+`;
+
+const IconNode = styled('div')`
+  position: absolute;
+  grid-column: 1;
+  grid-row: 1;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  color: ${p => p.theme.white};
+  box-shadow: ${p => p.theme.dropShadowLight};
+  user-select: none;
+  background-color: rgb(181, 19, 7, 0.2);
+`;

+ 117 - 0
static/app/views/issueDetails/traceTimeline/traceTimelineTooltip.tsx

@@ -0,0 +1,117 @@
+import styled from '@emotion/styled';
+
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Link from 'sentry/components/links/link';
+import {generateTraceTarget} from 'sentry/components/quickTrace/utils';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Event} from 'sentry/types';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+
+import type {TimelineEvent} from './useTraceTimelineEvents';
+
+export function TraceTimelineTooltip({
+  event,
+  timelineEvents,
+}: {event: Event; timelineEvents: TimelineEvent[]}) {
+  const organization = useOrganization();
+  const {projects} = useProjects({
+    slugs: [
+      ...timelineEvents.reduce((acc, cur) => acc.add(cur.project), new Set<string>()),
+    ],
+    orgId: organization.slug,
+  });
+  // TODO: should handling of current event + other events look different
+  if (timelineEvents.length === 1 && timelineEvents[0].id === event.id) {
+    return <YouAreHere>{t('You are here')}</YouAreHere>;
+  }
+
+  return (
+    <UnstyledUnorderedList>
+      <EventItemsWrapper>
+        {timelineEvents.slice(0, 3).map(timelineEvent => {
+          const titleSplit = timelineEvent.title.split(':');
+          const project = projects.find(p => p.slug === timelineEvent.project);
+
+          return (
+            <EventItem
+              key={timelineEvent.id}
+              to={`/organizations/${organization.slug}/issues/${timelineEvent['issue.id']}/events/${timelineEvent.id}/`}
+            >
+              <div>
+                {project && <ProjectBadge project={project} avatarSize={18} hideName />}
+              </div>
+              <EventTitleWrapper>
+                <EventTitle>{titleSplit[0]}</EventTitle>
+                <EventDescription>{titleSplit.slice(1).join('')}</EventDescription>
+              </EventTitleWrapper>
+            </EventItem>
+          );
+        })}
+      </EventItemsWrapper>
+      {timelineEvents.length > 3 && (
+        <TraceItem>
+          <Link to={generateTraceTarget(event, organization)}>
+            {t('View trace for %s more', timelineEvents.length - 3)}
+          </Link>
+        </TraceItem>
+      )}
+    </UnstyledUnorderedList>
+  );
+}
+
+const UnstyledUnorderedList = styled('div')`
+  display: flex;
+  flex-direction: column;
+  text-align: left;
+`;
+
+const EventItemsWrapper = styled('div')`
+  display: flex;
+  flex-direction: column;
+  padding: ${space(0.5)};
+`;
+
+const YouAreHere = styled('div')`
+  padding: ${space(1)};
+  font-weight: bold;
+  text-align: center;
+`;
+
+const EventItem = styled(Link)`
+  display: grid;
+  grid-template-columns: max-content auto;
+  color: ${p => p.theme.textColor};
+  gap: ${space(1)};
+  width: 100%;
+  padding: ${space(1)};
+  border-radius: ${p => p.theme.borderRadius};
+  min-height: 44px;
+
+  &:hover {
+    background-color: ${p => p.theme.surface200};
+    color: ${p => p.theme.textColor};
+  }
+`;
+
+const EventTitleWrapper = styled('div')`
+  width: 100%;
+  overflow: hidden;
+  line-height: 1.2;
+`;
+
+const EventTitle = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+  font-weight: 600;
+`;
+
+const EventDescription = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+`;
+
+const TraceItem = styled('div')`
+  padding: ${space(1)} ${space(1.5)};
+  border-radius: ${p => p.theme.borderRadius};
+  border-top: 1px solid ${p => p.theme.innerBorder};
+`;

+ 126 - 0
static/app/views/issueDetails/traceTimeline/useTraceTimelineEvents.tsx

@@ -0,0 +1,126 @@
+import {useMemo} from 'react';
+
+import type {Event} from 'sentry/types';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {getTraceTimeRangeFromEvent} from 'sentry/utils/performance/quickTrace/utils';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+
+export interface TimelineEvent {
+  id: string;
+  issue: string;
+  'issue.id': number;
+  project: string;
+  'project.name': string;
+  timestamp: string;
+  title: string;
+}
+
+export interface TraceEventResponse {
+  data: TimelineEvent[];
+  meta: unknown;
+}
+
+interface UseTraceTimelineEventsOptions {
+  event: Event;
+}
+
+export function useTraceTimelineEvents(
+  {event}: UseTraceTimelineEventsOptions,
+  isEnabled: boolean = true
+) {
+  const organization = useOrganization();
+  const {start, end} = getTraceTimeRangeFromEvent(event);
+
+  const traceId = event.contexts?.trace?.trace_id ?? '';
+  const enabled = !!traceId && isEnabled;
+  const {
+    data: issuePlatformData,
+    isLoading: isLoadingIssuePlatform,
+    isError: isErrorIssuePlatform,
+  } = useApiQuery<TraceEventResponse>(
+    [
+      `/organizations/${organization.slug}/events/`,
+      {
+        query: {
+          // Get performance issues
+          dataset: DiscoverDatasets.ISSUE_PLATFORM,
+          field: ['title', 'project', 'timestamp', 'issue.id', 'issue'],
+          per_page: 100,
+          query: `trace:${traceId}`,
+          referrer: 'api.issues.issue_events',
+          sort: '-timestamp',
+          start,
+          end,
+        },
+      },
+    ],
+    {staleTime: Infinity, retry: false, enabled}
+  );
+  const {
+    data: discoverData,
+    isLoading: isLoadingDiscover,
+    isError: isErrorDiscover,
+  } = useApiQuery<{
+    data: TimelineEvent[];
+    meta: unknown;
+  }>(
+    [
+      `/organizations/${organization.slug}/events/`,
+      {
+        query: {
+          // Other events
+          dataset: DiscoverDatasets.DISCOVER,
+          field: ['title', 'project', 'timestamp', 'issue.id', 'issue'],
+          per_page: 100,
+          query: `trace:${traceId}`,
+          referrer: 'api.issues.issue_events',
+          sort: '-timestamp',
+          start,
+          end,
+        },
+      },
+    ],
+    {staleTime: Infinity, retry: false, enabled}
+  );
+
+  const eventData = useMemo(() => {
+    if (
+      isLoadingIssuePlatform ||
+      isLoadingDiscover ||
+      isErrorIssuePlatform ||
+      isErrorDiscover
+    ) {
+      return {
+        data: [],
+        startTimestamp: 0,
+        endTimestamp: 0,
+      };
+    }
+
+    const events = [...issuePlatformData.data, ...discoverData.data];
+    const timestamps = events.map(e => new Date(e.timestamp).getTime());
+    const startTimestamp = Math.min(...timestamps);
+    const endTimestamp = Math.max(...timestamps);
+    return {
+      data: events,
+      startTimestamp,
+      endTimestamp,
+    };
+  }, [
+    issuePlatformData,
+    discoverData,
+    isLoadingIssuePlatform,
+    isLoadingDiscover,
+    isErrorIssuePlatform,
+    isErrorDiscover,
+  ]);
+
+  return {
+    data: eventData.data,
+    startTimestamp: eventData.startTimestamp,
+    endTimestamp: eventData.endTimestamp,
+    isLoading: isLoadingIssuePlatform || isLoadingDiscover,
+    isError: isErrorIssuePlatform || isErrorDiscover,
+  };
+}

+ 41 - 0
static/app/views/issueDetails/traceTimeline/utils.tsx

@@ -0,0 +1,41 @@
+import type {Organization, User} from 'sentry/types';
+
+import type {TimelineEvent} from './useTraceTimelineEvents';
+
+function getEventTimestamp(start: number, event: TimelineEvent) {
+  return new Date(event.timestamp).getTime() - start;
+}
+
+export function getEventsByColumn(
+  durationMs: number,
+  events: TimelineEvent[],
+  totalColumns: number,
+  start: number
+) {
+  const eventsByColumn = events.reduce((map, event) => {
+    const columnPositionCalc =
+      Math.floor((getEventTimestamp(start, event) / durationMs) * (totalColumns - 1)) + 1;
+
+    // Should start at minimum in the first column
+    const column = Math.max(1, columnPositionCalc);
+
+    if (map.has(column)) {
+      map.get(column)!.push(event);
+    } else {
+      map.set(column, [event]);
+    }
+    return map;
+  }, new Map<number, TimelineEvent[]>());
+
+  return eventsByColumn;
+}
+
+export function hasTraceTimelineFeature(
+  organization: Organization | null,
+  user: User | undefined
+) {
+  const newIssueExperienceEnabled = user?.options?.issueDetailsNewExperienceQ42023;
+  const hasFeature = organization?.features?.includes('issues-trace-timeline');
+
+  return !!(newIssueExperienceEnabled && hasFeature);
+}