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

ref(profiling): source code linking (#60777)

Enables source code linking for platforms containing javascript and
ports the source logic fetching to use react query. I also added a nicer
loading state.


https://github.com/getsentry/sentry/assets/9317857/acbbe7df-08d7-4409-ad20-3ffa59a9a5d7

Fixes https://github.com/getsentry/sentry/issues/60668
Jonas 1 год назад
Родитель
Сommit
e4ad6365f4

+ 53 - 84
static/app/components/profiling/flamegraph/flamegraphContextMenu.tsx

@@ -1,8 +1,10 @@
 import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
 import {createPortal} from 'react-dom';
 import {usePopper} from 'react-popper';
+import styled from '@emotion/styled';
 
 import Link from 'sentry/components/links/link';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Flex} from 'sentry/components/profiling/flex';
 import {
   ProfilingContextMenu,
@@ -14,8 +16,6 @@ import {
 } from 'sentry/components/profiling/profilingContextMenu';
 import {IconChevron, IconCopy, IconGithub, IconProfiling} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import {RequestState} from 'sentry/types';
-import {StacktraceLinkResult} from 'sentry/types/integrations';
 import {defined} from 'sentry/utils';
 import {getShortEventId} from 'sentry/utils/events';
 import {
@@ -27,9 +27,9 @@ import {useFlamegraphPreferences} from 'sentry/utils/profiling/flamegraph/hooks/
 import {useDispatchFlamegraphState} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphState';
 import {FlamegraphFrame} from 'sentry/utils/profiling/flamegraphFrame';
 import {useContextMenu} from 'sentry/utils/profiling/hooks/useContextMenu';
+import {useSourceCodeLink} from 'sentry/utils/profiling/hooks/useSourceLink';
 import {ProfileGroup} from 'sentry/utils/profiling/profile/importProfile';
 import {generateProfileFlamechartRouteWithHighlightFrame} from 'sentry/utils/profiling/routes';
-import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
 
@@ -66,78 +66,36 @@ function isSupportedPlatformForGitHubLink(platform: string | undefined): boolean
     return false;
   }
 
-  return platform === 'node' || platform === 'python';
+  return platform.includes('javascript') || platform === 'node' || platform === 'python';
 }
 
 export function FlamegraphContextMenu(props: FlamegraphContextMenuProps) {
-  const api = useApi();
   const {projects} = useProjects();
   const organization = useOrganization();
   const preferences = useFlamegraphPreferences();
   const dispatch = useDispatchFlamegraphState();
 
-  const [githubLink, setGithubLinkState] = useState<RequestState<StacktraceLinkResult>>({
-    type: 'initial',
-  });
-
   const project = projects.find(
     p => p.id === String(props.profileGroup?.metadata?.projectID)
   );
 
-  useEffect(() => {
-    if (!props.hoveredNode || !props.profileGroup) {
-      return setGithubLinkState({type: 'initial'});
-    }
-
-    if (
-      !project ||
-      !props.hoveredNode ||
-      // the profile ids indicate this is an aggregate flamegraph
-      // and they do not support source code links
-      props.hoveredNode.profileIds
-    ) {
-      return undefined;
-    }
-
-    if (!isSupportedPlatformForGitHubLink(props.profileGroup.metadata.platform)) {
-      return setGithubLinkState({type: 'errored', error: 'Unsupported platform'});
-    }
-
-    const metadata = props.profileGroup.metadata;
-    const commitId = metadata.release?.lastCommit?.id;
-    const platform = metadata.platform;
-
-    const frame = props.hoveredNode.frame;
-
-    setGithubLinkState({type: 'loading'});
-
-    api
-      .requestPromise(`/projects/${organization.slug}/${project.slug}/stacktrace-link/`, {
-        query: {
-          file: frame.file,
-          platform,
-          commitId,
-          ...(frame.path && {absPath: frame.path}),
-        },
-      })
-      .then(response => {
-        setGithubLinkState({type: 'resolved', data: response});
-      });
-
-    return () => {
-      api.clear();
-    };
-  }, [props.hoveredNode, api, project, organization, props.profileGroup]);
+  const sourceCodeLink = useSourceCodeLink({
+    project,
+    organization,
+    commitId: props.profileGroup?.metadata?.release?.lastCommit?.id,
+    platform: props.profileGroup?.metadata?.platform,
+    frame: {file: props.hoveredNode?.frame.file, path: props.hoveredNode?.frame.path},
+  });
 
   // @TODO: this only works for github right now, other providers will not work
   const onOpenInGithubClick = useCallback(() => {
-    if (githubLink.type !== 'resolved') {
+    if (!sourceCodeLink.isSuccess) {
       return;
     }
 
     if (
-      !githubLink.data.sourceUrl ||
-      githubLink.data.config?.provider?.key !== 'github'
+      !sourceCodeLink.data.sourceUrl ||
+      sourceCodeLink.data.config?.provider?.key !== 'github'
     ) {
       return;
     }
@@ -145,11 +103,11 @@ export function FlamegraphContextMenu(props: FlamegraphContextMenuProps) {
     // make a best effort to link to the exact line if we can
     const url =
       defined(props.hoveredNode) && defined(props.hoveredNode.frame.line)
-        ? `${githubLink.data.sourceUrl}#L${props.hoveredNode.frame.line}`
-        : githubLink.data.sourceUrl;
+        ? `${sourceCodeLink.data.sourceUrl}#L${props.hoveredNode.frame.line}`
+        : sourceCodeLink.data.sourceUrl;
 
     window.open(url, '_blank', 'noopener,noreferrer');
-  }, [props.hoveredNode, githubLink]);
+  }, [props.hoveredNode, sourceCodeLink]);
 
   return props.contextMenu.open ? (
     <Fragment>
@@ -211,31 +169,32 @@ export function FlamegraphContextMenu(props: FlamegraphContextMenuProps) {
             >
               {t('Copy source location')}
             </ProfilingContextMenuItemButton>
-            {githubLink.type !== 'initial' && (
-              <ProfilingContextMenuItemButton
-                disabled={
-                  githubLink.type !== 'resolved' ||
-                  !(githubLink.type === 'resolved' && githubLink.data.sourceUrl)
-                }
-                tooltip={
-                  !isSupportedPlatformForGitHubLink(
-                    props.profileGroup?.metadata?.platform
-                  )
-                    ? t('Open in GitHub is not yet supported for this platform')
-                    : githubLink.type === 'resolved' &&
-                      (!githubLink.data.sourceUrl ||
-                        githubLink.data.config?.provider?.key !== 'github')
-                    ? t('Could not find source code location in GitHub')
-                    : undefined
-                }
-                {...props.contextMenu.getMenuItemProps({
-                  onClick: onOpenInGithubClick,
-                })}
-                icon={<IconGithub size="xs" />}
-              >
-                {t('Open in GitHub')}
-              </ProfilingContextMenuItemButton>
-            )}
+            <ProfilingContextMenuItemButton
+              disabled={!sourceCodeLink.isSuccess || !sourceCodeLink.data?.sourceUrl}
+              tooltip={
+                !isSupportedPlatformForGitHubLink(props.profileGroup?.metadata?.platform)
+                  ? t('Open in GitHub is not supported for this platform')
+                  : sourceCodeLink.isLoading
+                  ? 'Resolving link'
+                  : sourceCodeLink.isSuccess &&
+                    (!sourceCodeLink.data.sourceUrl ||
+                      sourceCodeLink.data.config?.provider?.key !== 'github')
+                  ? t('Could not find source code location in GitHub')
+                  : undefined
+              }
+              {...props.contextMenu.getMenuItemProps({
+                onClick: onOpenInGithubClick,
+              })}
+              icon={
+                sourceCodeLink.isLoading ? (
+                  <StyledLoadingIndicator size={10} hideMessage />
+                ) : (
+                  <IconGithub size="xs" />
+                )
+              }
+            >
+              {t('Open in GitHub')}
+            </ProfilingContextMenuItemButton>
           </ProfilingContextMenuGroup>
         ) : null}
         {props.disableColorCoding ? null : (
@@ -296,6 +255,16 @@ export function FlamegraphContextMenu(props: FlamegraphContextMenuProps) {
   ) : null;
 }
 
+const StyledLoadingIndicator = styled(LoadingIndicator)`
+  margin: 0;
+  transform: translateX(-2px);
+
+  > div {
+    border: 2px solid ${p => p.theme.gray100} !important;
+    border-left-color: ${p => p.theme.gray200} !important;
+  }
+`;
+
 function ProfileIdsSubMenu(props: {
   contextMenu: FlamegraphContextMenuProps['contextMenu'];
   frameName: string;

+ 43 - 0
static/app/utils/profiling/hooks/useSourceLink.tsx

@@ -0,0 +1,43 @@
+import {StacktraceLinkResult} from 'sentry/types/integrations';
+import {Organization} from 'sentry/types/organization';
+import {Project} from 'sentry/types/project';
+import {useApiQuery, UseApiQueryResult} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+
+interface UseSourceCodeLinkProps {
+  commitId: string | undefined;
+  frame: {
+    file: string | undefined;
+    path: string | undefined;
+  };
+  organization: Organization;
+  platform: string | undefined;
+  project: Project | undefined;
+}
+
+export function useSourceCodeLink(
+  props: UseSourceCodeLinkProps
+): UseApiQueryResult<StacktraceLinkResult, RequestError> {
+  return useApiQuery<StacktraceLinkResult>(
+    [
+      `/projects/${props.organization.slug}/${props.project?.slug}/stacktrace-link/`,
+      {
+        query: {
+          file: props.frame.file,
+          platform: props.platform,
+          commitId: props.commitId,
+          ...(props.frame.path && {absPath: props.frame.path}),
+        },
+      },
+    ],
+    {
+      enabled: !!(
+        props.project &&
+        props.platform &&
+        props.frame &&
+        (props.frame.file || props.frame.path)
+      ),
+      staleTime: Infinity,
+    }
+  );
+}

+ 4 - 1
static/app/utils/queryClient.tsx

@@ -67,7 +67,10 @@ interface UseApiQueryOptions<TApiResponse, TError = RequestError>
   staleTime: number;
 }
 
-type UseApiQueryResult<TData, TError> = reactQuery.UseQueryResult<TData, TError> & {
+export type UseApiQueryResult<TData, TError> = reactQuery.UseQueryResult<
+  TData,
+  TError
+> & {
   /**
    * Get a header value from the response
    */