Repository: getsentry/sentry
Pull request: ref(traces): Add 'open in metrics' button
### Goal
On the traces pages, we display a '+' button that allows users to add a new metric based on the span attribute when rendering the additional data card. This PR introduces a new button, 'Open in Metrics', which appears when an existing metrics rule is already defined for the span attribute. By clicking this button, users will be directed to the metrics page, pre-filtered with the first condition (query) and the first aggregation (agg by).

**Preview**




https://github.com/user-attachments/assets/cab3cd06-7087-43e9-b777-5ecfbc96c743



### Note:

~We typically render a very large button for 'Open in Discover'. However, this might be too much. I have configured the 'Open in Metrics' button to be 'xs' but feedback is welcome :)~ Now its a link 

closes https://github.com/getsentry/sentry/issues/74622

Problem statement:
Check for existing extraction rules in trace view
- if we already have a rule for that attribute, show Open in Metrics - otherwise keep the + icon
- open in metrics will adjust the time window to +/- 30 mins of that span

Hints from issue discussion:

Commit message: feedback

Patch to review:
diff --git a/static/app/components/keyValueData/index.tsx b/static/app/components/keyValueData/index.tsx
index f40e1dc2020b5b..919e435cacc59e 100644
--- a/static/app/components/keyValueData/index.tsx
+++ b/static/app/components/keyValueData/index.tsx
@@ -1,5 +1,6 @@
 import {Children, isValidElement, type ReactNode, useRef, useState} from 'react';
 import React from 'react';
+import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
@@ -47,7 +48,14 @@ export function Content({
   disableFormattedData = false,
   ...props
 }: KeyValueDataContentProps) {
-  const {subject, subjectNode, value: contextValue, action = {}, actionButton} = item;
+  const {
+    subject,
+    subjectNode,
+    value: contextValue,
+    action = {},
+    actionButton,
+    actionButtonAlwaysVisible,
+  } = item;
 
   const hasErrors = errors.length > 0;
   const hasSuffix = !!(hasErrors || actionButton);
@@ -82,7 +90,11 @@ export function Content({
         {hasSuffix && (
           <div>
             {hasErrors && <AnnotatedTextErrors errors={errors} />}
-            {actionButton && <ActionButtonWrapper>{actionButton}</ActionButtonWrapper>}
+            {actionButton && (
+              <ActionButtonWrapper actionButtonAlwaysVisible={actionButtonAlwaysVisible}>
+                {actionButton}
+              </ActionButtonWrapper>
+            )}
           </div>
         )}
       </ValueSection>
@@ -276,11 +288,15 @@ const ValueLink = styled(Link)`
   text-decoration: ${p => p.theme.linkUnderline} underline dotted;
 `;
 
-const ActionButtonWrapper = styled('div')`
-  visibility: hidden;
-  ${ContentWrapper}:hover & {
-    visibility: visible;
-  }
+const ActionButtonWrapper = styled('div')<{actionButtonAlwaysVisible?: boolean}>`
+  ${p =>
+    !p.actionButtonAlwaysVisible &&
+    css`
+      visibility: hidden;
+      ${ContentWrapper}:hover & {
+        visibility: visible;
+      }
+    `}
 `;
 
 export const KeyValueData = {
diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx
index 5735107c1e5fa1..0000d49c14fd34 100644
--- a/static/app/types/group.tsx
+++ b/static/app/types/group.tsx
@@ -902,6 +902,10 @@ export type KeyValueListDataItem = {
     link?: string | LocationDescriptor;
   };
   actionButton?: React.ReactNode;
+  /**
+   * If true, the action button will always be visible, not just on hover.
+   */
+  actionButtonAlwaysVisible?: boolean;
   isContextData?: boolean;
   isMultiValue?: boolean;
   meta?: Meta;
diff --git a/static/app/utils/metrics/virtualMetricsContext.tsx b/static/app/utils/metrics/virtualMetricsContext.tsx
index b3378aa7ca1f62..4aaa31aba1dec0 100644
--- a/static/app/utils/metrics/virtualMetricsContext.tsx
+++ b/static/app/utils/metrics/virtualMetricsContext.tsx
@@ -57,7 +57,7 @@ interface Props {
   children: React.ReactNode;
 }
 
-function createVirtualMRI(rule: MetricsExtractionRule): MRI {
+export function createVirtualMRI(rule: MetricsExtractionRule): MRI {
   return `v:custom/${rule.spanAttribute}|${rule.projectId}@${rule.unit}`;
 }
 
diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/keys.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/keys.tsx
index 57d4ea200ee8b9..3d26d1c1508a20 100644
--- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/keys.tsx
+++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/keys.tsx
@@ -1,4 +1,5 @@
 import {Fragment, useMemo} from 'react';
+import {Link} from 'react-router';
 
 import {Button} from 'sentry/components/button';
 import {
@@ -16,19 +17,31 @@ import ExternalLink from 'sentry/components/links/externalLink';
 import {IconAdd} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import type {
+  KeyValueListDataItem,
+  MetricsExtractionRule,
+  Organization,
+} from 'sentry/types';
 import {defined} from 'sentry/utils';
+import {getUtcDateString} from 'sentry/utils/dates';
+import {getMetricsUrl} from 'sentry/utils/metrics';
 import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
+import {MetricDisplayType} from 'sentry/utils/metrics/types';
+import {createVirtualMRI} from 'sentry/utils/metrics/virtualMetricsContext';
 import {toTitleCase} from 'sentry/utils/string/toTitleCase';
 import useOrganization from 'sentry/utils/useOrganization';
 import {isSpanNode} from 'sentry/views/performance/newTraceDetails/guards';
+import {
+  type SectionCardKeyValueList,
+  TraceDrawerComponents,
+} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';
 import {
   type TraceTree,
   TraceTreeNode,
 } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
 import {getPerformanceDuration} from 'sentry/views/performance/utils/getPerformanceDuration';
 import {openExtractionRuleCreateModal} from 'sentry/views/settings/projectMetrics/metricsExtractionRuleCreateModal';
-
-import {type SectionCardKeyValueList, TraceDrawerComponents} from '../../styles';
+import {useMetricsExtractionRules} from 'sentry/views/settings/projectMetrics/utils/useMetricsExtractionRules';
 
 const SIZE_DATA_KEYS = [
   'Encoded Body Size',
@@ -64,6 +77,92 @@ function partitionSizes(data: RawSpanType['data']): {
   };
 }
 
+function getSpanTimeWindow(timestamp: number) {
+  // Convert 30 minutes to miliseconds
+  const thirtyMinutesInMiliSeconds = 30 * 60 * 1000;
+
+  // Calculate the start and end of the time window
+  const startTime = timestamp - thirtyMinutesInMiliSeconds;
+  const endTime = timestamp + thirtyMinutesInMiliSeconds;
+
+  return {
+    startTime: startTime,
+    endTime: endTime,
+  };
+}
+
+function getNonSizeKeyActionData({
+  organization,
+  extractionRules,
+  spanAttribute,
+  projectId,
+  spanTimestamp,
+}: {
+  organization: Organization;
+  spanAttribute: string;
+  spanTimestamp: number;
+  extractionRules?: MetricsExtractionRule[];
+  projectId?: string;
+}): Pick<KeyValueListDataItem, 'actionButton' | 'actionButtonAlwaysVisible'> {
+  if (!hasCustomMetricsExtractionRules(organization)) {
+    return {};
+  }
+
+  const extractionRule = extractionRules?.find(
+    rule => rule.spanAttribute === spanAttribute
+  );
+
+  if (extractionRule) {
+    const virtualMRI = createVirtualMRI(extractionRule);
+    const spanTimeWindow = getSpanTimeWindow(spanTimestamp * 1000);
+    return {
+      actionButton: (
+        <Link
+          to={getMetricsUrl(organization.slug, {
+            start: getUtcDateString(spanTimeWindow.startTime),
+            end: getUtcDateString(spanTimeWindow.endTime),
+            project: [extractionRule.projectId],
+            widgets: [
+              {
+                mri: virtualMRI,
+                displayType: MetricDisplayType.BAR,
+                aggregation: extractionRule.aggregates[0],
+                query: '',
+                groupBy: undefined,
+                condition: extractionRule.conditions[0].id,
+              },
+            ],
+          })}
+        >
+          {t('Open in Metrics')}
+        </Link>
+      ),
+      actionButtonAlwaysVisible: true,
+    };
+  }
+
+  return {
+    actionButton: (
+      <Button
+        borderless
+        aria-label={t('Extract as metric')}
+        onClick={() =>
+          openExtractionRuleCreateModal({
+            projectId: projectId,
+            initialData: {
+              spanAttribute,
+            },
+          })
+        }
+        size="zero"
+        icon={<IconAdd size="xs" />}
+        title={t('Extract as metric')}
+      />
+    ),
+    actionButtonAlwaysVisible: false,
+  };
+}
+
 export function SpanKeys({
   node,
   projectId,
@@ -72,6 +171,10 @@ export function SpanKeys({
   projectId?: string;
 }) {
   const organization = useOrganization();
+  const {data: extractionRules} = useMetricsExtractionRules({
+    orgId: organization.slug,
+    projectId,
+  });
 
   const span = node.value;
   const {sizeKeys, nonSizeKeys} = partitionSizes(span?.data ?? {});
@@ -142,24 +245,14 @@ export function SpanKeys({
       items.push({
         key: key,
         subject: key,
-        actionButton: hasCustomMetricsExtractionRules(organization) ? (
-          <Button
-            borderless
-            aria-label={t('Extract as metric')}
-            onClick={() =>
-              openExtractionRuleCreateModal({
-                projectId: projectId,
-                initialData: {
-                  spanAttribute: key,
-                },
-              })
-            }
-            size="zero"
-            icon={<IconAdd size="xs" />}
-            title={t('Extract as metric')}
-          />
-        ) : undefined,
         value: value as string | number,
+        ...getNonSizeKeyActionData({
+          organization,
+          extractionRules,
+          spanAttribute: key,
+          projectId,
+          spanTimestamp: span.timestamp,
+        }),
       });
     }
   });


Code review comments:
diff_hunk: @@ -1,4 +1,5 @@
 import {Fragment, useMemo} from 'react';
+import {Link} from 'react-router';
path: static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/keys.tsx
text: @author:
```suggestion
import Link from 'sentry/components/links/link';
```