Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 149 additions & 28 deletions static/app/views/insights/pages/agents/components/aiSpanList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,31 +68,37 @@ export function AISpanList({
nodes,
selectedNodeKey,
onSelectNode,
compressGaps = false,
}: {
nodes: AITraceSpanNode[];
onSelectNode: (node: AITraceSpanNode) => void;
selectedNodeKey: string | null;
compressGaps?: boolean;
}) {
const nodesByTransaction = useMemo(() => {
const result: Map<TransactionNode | EapSpanNode, AITraceSpanNode[]> = new Map();
const result: Map<
TransactionNode | EapSpanNode | AITraceSpanNode,
AITraceSpanNode[]
> = new Map();
// Use a placeholder key for nodes without a transaction (e.g., conversation view)
let orphanGroup: AITraceSpanNode | null = null;

for (const node of nodes) {
// TODO: We should consider using BaseNode.expand to control toggle state,
// instead of grouping by transactions for toggling by transactions only.
// This would allow us to avoid using type guards/checks like below, outside of the BaseNode classes.
const isNodeTransaction =
isTransactionNode(node) || (isEAPSpanNode(node) && node.value.is_transaction);
const transaction = isNodeTransaction ? node : node.findClosestParentTransaction();
if (!transaction) {
continue;
}
const transactionNodes = result.get(transaction) || [];
result.set(transaction, [...transactionNodes, node]);
const groupKey = transaction ?? (orphanGroup ??= node);
const transactionNodes = result.get(groupKey) || [];
result.set(groupKey, [...transactionNodes, node]);
}
return result;
}, [nodes]);

return (
<Stack padding="2xs" gap="xs" overflow="hidden">
<Stack gap="xs">
{Array.from(nodesByTransaction.entries()).map(([transaction, transactionNodes]) => (
<TransactionWrapper
key={transaction.id}
Expand All @@ -101,6 +107,7 @@ export function AISpanList({
nodes={transactionNodes}
onSelectNode={onSelectNode}
selectedNodeKey={selectedNodeKey}
compressGaps={compressGaps}
/>
))}
</Stack>
Expand All @@ -113,17 +120,24 @@ function TransactionWrapper({
onSelectNode,
selectedNodeKey,
transaction,
compressGaps = false,
}: {
canCollapse: boolean;
nodes: AITraceSpanNode[];
onSelectNode: (node: AITraceSpanNode) => void;
selectedNodeKey: string | null;
transaction: TransactionNode | EapSpanNode;
transaction: TransactionNode | EapSpanNode | AITraceSpanNode;
compressGaps?: boolean;
}) {
const [isExpanded, setIsExpanded] = useState(true);
const theme = useTheme();
const colors = [...theme.chart.getColorPalette(5), theme.colors.red400];
const timeBounds = getNodeTimeBounds(nodes);

const compressedBounds = useMemo(
() => (compressGaps ? getCompressedTimeBounds(nodes) : null),
[compressGaps, nodes]
);
const timeBounds = compressedBounds ?? getNodeTimeBounds(nodes);

const nodeAiRunParentsMap = useMemo<Record<string, AITraceSpanNode>>(() => {
const parents: Record<string, AITraceSpanNode> = {};
Expand All @@ -142,21 +156,25 @@ function TransactionWrapper({
setIsExpanded(prevValue => !prevValue);
};

const title =
'transaction' in transaction.value
? transaction.value.transaction
: transaction.value.description;

const showHeader = canCollapse || !!title;

return (
<Fragment>
<TransactionButton type="button" disabled={!canCollapse} onClick={handleCollapse}>
{canCollapse ? (
<StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
) : null}
<Tooltip
title={transaction.value.transaction}
showOnlyOnOverflow
skipWrapper
delay={500}
>
<span>{transaction.value.transaction}</span>
</Tooltip>
</TransactionButton>
{showHeader && (
<TransactionButton type="button" disabled={!canCollapse} onClick={handleCollapse}>
{canCollapse ? (
<StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
) : null}
<Tooltip title={title} showOnlyOnOverflow skipWrapper delay={500}>
<span>{title}</span>
</Tooltip>
</TransactionButton>
)}
{isExpanded &&
nodes.map(node => {
const aiRunNode = nodeAiRunParentsMap[node.id];
Expand All @@ -174,6 +192,7 @@ function TransactionWrapper({
onClick={() => onSelectNode(node)}
isSelected={uniqueKey === selectedNodeKey}
colors={colors}
compressedStartByNodeId={compressedBounds?.compressedStartByNodeId}
/>
);
})}
Expand All @@ -188,18 +207,24 @@ const TraceListItem = memo(function TraceListItem({
colors,
traceBounds,
indent,
compressedStartByNodeId,
}: {
colors: readonly string[];
indent: number;
isSelected: boolean;
node: AITraceSpanNode;
onClick: () => void;
traceBounds: TraceBounds;
compressedStartByNodeId?: Map<string, number>;
}) {
const hasErrors = hasError(node);
const {icon, title, subtitle, color} = getNodeInfo(node, colors);
const safeColor = color || colors[0] || '#9ca3af';
const relativeTiming = calculateRelativeTiming(node, traceBounds);
const relativeTiming = calculateRelativeTiming(
node,
traceBounds,
compressedStartByNodeId
);
const duration = getNodeTimeBounds(node).duration;

return (
Expand Down Expand Up @@ -266,9 +291,97 @@ interface TraceBounds {
startTime: number;
}

interface CompressedTimeBounds extends TraceBounds {
compressedStartByNodeId: Map<string, number>;
}

const MAX_GAP_SECONDS = 30;
const COMPRESSED_GAP_SECONDS = 1;

/**
* Compresses large time gaps between spans to make the timeline more readable.
* Gaps larger than MAX_GAP_SECONDS are compressed to COMPRESSED_GAP_SECONDS.
*
* This function handles nested/overlapping spans by tracking "segments" - continuous
* time ranges where spans are active. When a gap is detected between segments,
* it's compressed if larger than MAX_GAP_SECONDS.
*
* Returns a Map of node IDs to their compressed start times, which allows O(1)
* lookup when rendering each span's position on the timeline.
*/
function getCompressedTimeBounds(nodes: AITraceSpanNode[]): CompressedTimeBounds {
Comment on lines 293 to +312
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got a few problems with this function:

  1. Too complex and costly for what it does as it has to iterate the segments on each call of getCompressedTimestamp. Instead we could just loop over it once and store the new timestamp for each span in a map.
  2. It ignores nested spans, simply summing up all durations and inflating the total time span. This results in the last span not ending the timeline (see screenshot).
  3. A few more comments on such a complex logic would help me figure out what it does 😅
Image

Something like this could better suit your needs:

function getCompressedTimeBounds(nodes: AITraceSpanNode[]): CompressedTimeBounds {
  const emptyResult: CompressedTimeBounds = {
    startTime: 0,
    endTime: 0,
    duration: 0,
    compressedStartByNodeId: new Map(),
  };

  if (nodes.length === 0) {
    return emptyResult;
  }

  const sortedNodes = [...nodes]
    .filter(n => n.startTimestamp && n.endTimestamp)
    .sort((a, b) => (a.startTimestamp ?? 0) - (b.startTimestamp ?? 0));

  if (sortedNodes.length === 0) {
    return emptyResult;
  }

  const compressedStartByNodeId = new Map<string, number>();

  // Track current segment bounds
  const firstNode = sortedNodes[0]!;
  let segmentRealStart = firstNode.startTimestamp!;
  let segmentRealEnd = firstNode.endTimestamp!;
  let segmentCompressedStart = 0;

  compressedStartByNodeId.set(firstNode.id, 0);

  for (let i = 1; i < sortedNodes.length; i++) {
    const node = sortedNodes[i]!;
    const nodeStart = node.startTimestamp!;
    const nodeEnd = node.endTimestamp!;

    if (nodeStart > segmentRealEnd) {
      // Gap detected - finish current segment and start new one
      const gap = nodeStart - segmentRealEnd;
      const compressedGap = gap > MAX_GAP_SECONDS ? COMPRESSED_GAP_SECONDS : gap;
      const segmentDuration = segmentRealEnd - segmentRealStart;

      // Advance compressed time by segment duration + gap
      segmentCompressedStart += segmentDuration + compressedGap;

      // Start new segment
      segmentRealStart = nodeStart;
      segmentRealEnd = nodeEnd;
    } else {
      // Overlapping - extend current segment
      segmentRealEnd = Math.max(segmentRealEnd, nodeEnd);
    }

    // Calculate this node's compressed start
    const offsetInSegment = nodeStart - segmentRealStart;
    compressedStartByNodeId.set(node.id, segmentCompressedStart + offsetInSegment);
  }

  // Total duration is the compressed start of last segment + its duration
  const totalDuration = segmentCompressedStart + (segmentRealEnd - segmentRealStart);

  return {
    startTime: 0,
    endTime: totalDuration,
    duration: totalDuration,
    compressedStartByNodeId,
  };
}

const emptyResult: CompressedTimeBounds = {
startTime: 0,
endTime: 0,
duration: 0,
compressedStartByNodeId: new Map(),
};

if (nodes.length === 0) {
return emptyResult;
}

const sortedNodes = [...nodes]
.filter(n => n.startTimestamp && n.endTimestamp)
.sort((a, b) => (a.startTimestamp ?? 0) - (b.startTimestamp ?? 0));

if (sortedNodes.length === 0) {
return emptyResult;
}

const compressedStartByNodeId = new Map<string, number>();

// Track current segment bounds - a segment is a continuous time range
// where at least one span is active (handles overlapping/nested spans)
const firstNode = sortedNodes[0]!;
let segmentRealStart = firstNode.startTimestamp!;
let segmentRealEnd = firstNode.endTimestamp!;
let segmentCompressedStart = 0;

compressedStartByNodeId.set(firstNode.id, 0);

for (let i = 1; i < sortedNodes.length; i++) {
const node = sortedNodes[i]!;
const nodeStart = node.startTimestamp!;
const nodeEnd = node.endTimestamp!;

if (nodeStart > segmentRealEnd) {
// Gap detected - finish current segment and start new one
const gap = nodeStart - segmentRealEnd;
const compressedGap = gap > MAX_GAP_SECONDS ? COMPRESSED_GAP_SECONDS : gap;
const segmentDuration = segmentRealEnd - segmentRealStart;

// Advance compressed time by segment duration + gap
segmentCompressedStart += segmentDuration + compressedGap;

// Start new segment
segmentRealStart = nodeStart;
segmentRealEnd = nodeEnd;
} else {
// Overlapping/nested span - extend current segment if needed
segmentRealEnd = Math.max(segmentRealEnd, nodeEnd);
}

// Calculate this node's compressed start relative to the current segment
const offsetInSegment = nodeStart - segmentRealStart;
compressedStartByNodeId.set(node.id, segmentCompressedStart + offsetInSegment);
}

// Total duration is the compressed start of last segment + its duration
const totalDuration = segmentCompressedStart + (segmentRealEnd - segmentRealStart);

return {
startTime: 0,
endTime: totalDuration,
duration: totalDuration,
compressedStartByNodeId,
};
}

function calculateRelativeTiming(
node: AITraceSpanNode,
traceBounds: TraceBounds
traceBounds: TraceBounds,
compressedStartByNodeId?: Map<string, number>
): {leftPercent: number; widthPercent: number} {
if (!node.value) return {leftPercent: 0, widthPercent: 0};

Expand All @@ -283,11 +396,19 @@ function calculateRelativeTiming(

if (traceBounds.duration === 0) return {leftPercent: 0, widthPercent: 0};

const relativeStart =
Math.max(0, (startTime - traceBounds.startTime) / traceBounds.duration) * 100;
const spanDuration = ((endTime - startTime) / traceBounds.duration) * 100;
// Look up the pre-computed compressed start time for this node.
// The span duration stays the same - only gaps between spans are compressed.
const compressedStart = compressedStartByNodeId?.get(node.id);
const effectiveStart =
compressedStart === undefined ? startTime - traceBounds.startTime : compressedStart;
const effectiveEnd =
compressedStart === undefined
? endTime - traceBounds.startTime
: compressedStart + (endTime - startTime);

const relativeStart = Math.max(0, effectiveStart / traceBounds.duration) * 100;
const spanDuration = ((effectiveEnd - effectiveStart) / traceBounds.duration) * 100;

// Minimum width of 2% for very short spans
const minWidth = 2;
const adjustedWidth = Math.max(spanDuration, minWidth);

Expand Down
65 changes: 1 addition & 64 deletions static/app/views/insights/pages/agents/hooks/useAITrace.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {useEffect, useState} from 'react';

import type {Client} from 'sentry/api';
import type {Organization} from 'sentry/types/organization';
import useApi from 'sentry/utils/useApi';
import useOrganization from 'sentry/utils/useOrganization';
import {getIsAiNode} from 'sentry/views/insights/pages/agents/utils/aiTraceNodes';
Expand All @@ -25,7 +23,7 @@ interface UseAITraceResult {
/**
* Base attributes needed for AI trace display
*/
export const AI_TRACE_BASE_ATTRIBUTES = [
const AI_TRACE_BASE_ATTRIBUTES = [
SpanFields.GEN_AI_AGENT_NAME,
SpanFields.GEN_AI_FUNCTION_ID,
SpanFields.GEN_AI_REQUEST_MODEL,
Expand All @@ -39,67 +37,6 @@ export const AI_TRACE_BASE_ATTRIBUTES = [
'status',
];

/**
* Additional attributes needed for conversation messages display
*/
export const AI_CONVERSATION_ATTRIBUTES = [
SpanFields.GEN_AI_CONVERSATION_ID,
SpanFields.GEN_AI_REQUEST_MESSAGES,
SpanFields.GEN_AI_RESPONSE_TEXT,
SpanFields.GEN_AI_RESPONSE_OBJECT,
SpanFields.GEN_AI_RESPONSE_TOOL_CALLS,
SpanFields.USER_ID,
SpanFields.USER_EMAIL,
SpanFields.USER_USERNAME,
SpanFields.USER_IP,
];

/**
* Processes trace data to extract AI-related nodes.
* Builds a TraceTree, fetches children for fetchable nodes, then filters for AI nodes.
*/
export async function processTraceForAINodes(
traceData: TraceTree.Trace,
api: Client,
organization: Organization
): Promise<AITraceSpanNode[]> {
const tree = TraceTree.FromTrace(traceData, {
meta: null,
replay: null,
preferences: DEFAULT_TRACE_VIEW_PREFERENCES,
organization,
});

tree.build();

const fetchableNodes = tree.root.findAllChildren(node => node.canFetchChildren);

const uniqueNodes = fetchableNodes.filter(
(node, index, array) => index === array.findIndex(n => n.id === node.id)
);

const fetchPromises = uniqueNodes.map(node =>
tree.fetchNodeSubTree(true, node, {
api,
organization,
preferences: DEFAULT_TRACE_VIEW_PREFERENCES,
})
);

await Promise.all(fetchPromises);

// Keep only transactions that include AI spans and the AI spans themselves
const flattenedNodes = tree.root.findAllChildren<AITraceSpanNode>(node => {
if (!isTransactionNode(node) && !isSpanNode(node) && !isEAPSpanNode(node)) {
return false;
}

return getIsAiNode(node);
});

return flattenedNodes;
}

export function useAITrace(traceSlug: string, timestamp?: number): UseAITraceResult {
const [nodes, setNodes] = useState<AITraceSpanNode[]>([]);
const [isLoading, setIsLoading] = useState(true);
Expand Down
Loading
Loading