feat: Improve Service Maps (#1353)

# Summary

This PR makes a number of minor fixes and improvements to the Service Maps feature:

1. The Service Map now has its own tab in the side panel. This resolves usability issues such as the trace panel capturing scroll events and appearing too large on the side panel. Closes HDX-2785, Closes HDX-2732.
2. On single-trace service maps (eg. the one on the side panel), request counts are now rendered as exact numbers (eg. `1 request`), rather than approximate numbers (eg. `~1 request`). Closes HDX-2741.
3. Service map viewport bounds are now reset when the input data changes (typically when the source or sampling level changes). Closes HDX-2778.
4. Service maps now have an empty state. Closes HDX-2739.

<img width="1359" height="902" alt="Screenshot 2025-11-11 at 11 00 05 PM" src="https://github.com/user-attachments/assets/6d8c7fda-bf4e-4dbe-83e4-6395f53511cb" />
<img width="1365" height="910" alt="Screenshot 2025-11-11 at 11 05 13 PM" src="https://github.com/user-attachments/assets/af5218f9-43f8-4536-abee-5ce090cf0438" />
This commit is contained in:
Drew Davis 2025-11-14 11:40:52 -05:00 committed by GitHub
parent 5e440ab390
commit a7e150c825
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 139 additions and 35 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Improve Service Maps

View file

@ -14,7 +14,7 @@ import { ErrorBoundary } from 'react-error-boundary';
import { useHotkeys } from 'react-hotkeys-hook';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { Box, Drawer, Stack } from '@mantine/core';
import { Box, Drawer, Flex, Stack } from '@mantine/core';
import { useClickOutside } from '@mantine/hooks';
import DBRowSidePanelHeader, {
@ -28,6 +28,7 @@ import TabBar from '@/TabBar';
import { SearchConfig } from '@/types';
import { useZIndex, ZIndexContext } from '@/zIndex';
import ServiceMapSidePanel from './ServiceMap/ServiceMapSidePanel';
import ContextSubpanel from './ContextSidePanel';
import DBInfraPanel from './DBInfraPanel';
import { RowDataPanel, useRowData } from './DBRowDataPanel';
@ -70,6 +71,7 @@ enum Tab {
Parsed = 'parsed',
Debug = 'debug',
Trace = 'trace',
ServiceMap = 'serviceMap',
Context = 'context',
Replay = 'replay',
Infrastructure = 'infrastructure',
@ -221,6 +223,8 @@ const DBRowSidePanel = ({
const traceSourceId =
source.kind === 'trace' ? source.id : source.traceSourceId;
const enableServiceMap = traceId && traceSourceId;
const { rumSessionId, rumServiceName } = useSessionId({
sourceId: traceSourceId,
traceId,
@ -303,6 +307,14 @@ const DBRowSidePanel = ({
text: 'Trace',
value: Tab.Trace,
},
...(enableServiceMap
? [
{
text: 'Service Map',
value: Tab.ServiceMap,
},
]
: []),
{
text: 'Surrounding Context',
value: Tab.Context,
@ -370,6 +382,26 @@ const DBRowSidePanel = ({
</Box>
</ErrorBoundary>
)}
{displayedTab === Tab.ServiceMap && enableServiceMap && (
<ErrorBoundary
onError={err => {
console.error(err);
}}
fallbackRender={() => (
<div className="text-danger px-2 py-1 m-2 fs-7 font-monospace bg-danger-transparent p-4">
An error occurred while rendering this event.
</div>
)}
>
<Flex p="sm" flex={1}>
<ServiceMapSidePanel
traceId={traceId}
traceTableSourceId={traceSourceId}
dateRange={oneHourRange}
/>
</Flex>
</ErrorBoundary>
)}
{displayedTab === Tab.Parsed && (
<ErrorBoundary
onError={err => {

View file

@ -209,28 +209,6 @@ export default function DBTracePanel({
)}
{traceSourceData != null && eventRowWhere != null && (
<>
<Group>
<Text size="sm" c="dark.2" my="sm">
Service Map
</Text>
<Badge
size="xs"
color="gray.4"
autoContrast
radius="sm"
className="align-text-bottom"
>
Beta
</Badge>
</Group>
<div style={{ height: '300px', width: '100%', display: 'flex' }}>
<ServiceMap
traceId={traceId}
traceTableSource={traceSourceData}
dateRange={dateRange}
/>
</div>
<Divider my="md" />
<Text size="sm" c="dark.2" my="sm">
Event Details
</Text>

View file

@ -15,6 +15,8 @@ import {
NodeChange,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
} from '@xyflow/react';
import useServiceMap, { ServiceAggregation } from '@/hooks/useServiceMap';
@ -74,6 +76,7 @@ interface ServiceMapPresentationProps {
error: Error | null;
dateRange: [Date, Date];
source: TSource;
isSingleTrace?: boolean;
}
function ServiceMapPresentation({
@ -82,9 +85,16 @@ function ServiceMapPresentation({
error,
dateRange,
source,
isSingleTrace,
}: ServiceMapPresentationProps) {
const [nodes, setNodes] = useState<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const { fitView } = useReactFlow();
// Fit the data to the viewport whenever input service information changes
useEffect(() => {
fitView();
}, [fitView, services]);
const onNodesChange = useCallback(
(changes: NodeChange<Node>[]) =>
@ -115,6 +125,7 @@ function ServiceMapPresentation({
dateRange,
source,
maxErrorPercentage,
isSingleTrace,
},
position: { x: index * 150, y: 100 },
type: 'service',
@ -143,6 +154,7 @@ function ServiceMapPresentation({
source,
dateRange,
serviceName,
isSingleTrace,
},
};
},
@ -153,16 +165,27 @@ function ServiceMapPresentation({
setNodes(nodeWithLayout);
setEdges(edges);
}, [services, dateRange, source, maxErrorPercentage]);
}, [services, dateRange, source, maxErrorPercentage, isSingleTrace]);
if (isLoading) {
return (
<Center className={`${styles.graphContainer} h-100`}>
<Center className={`${styles.graphContainer} h-100 w-100`}>
<Loader size="lg" />
</Center>
);
}
if (services && services.size === 0) {
return (
<Center className="w-100 h-100">
<Text size="sm" c="gray.5">
No services found. The Service Map shows links between services with
related Client- and Server-kind spans.
</Text>
</Center>
);
}
if (error) {
return (
<Box>
@ -222,6 +245,7 @@ interface ServiceMapProps {
traceTableSource: TSource;
dateRange: [Date, Date];
samplingFactor?: number;
isSingleTrace?: boolean;
}
export default function ServiceMap({
@ -229,6 +253,7 @@ export default function ServiceMap({
traceTableSource,
dateRange,
samplingFactor = 1,
isSingleTrace,
}: ServiceMapProps) {
const {
isLoading,
@ -252,12 +277,15 @@ export default function ServiceMap({
}, [error]);
return (
<ServiceMapPresentation
services={services}
isLoading={isLoading}
error={error}
dateRange={dateRange}
source={traceTableSource}
/>
<ReactFlowProvider>
<ServiceMapPresentation
services={services}
isLoading={isLoading}
error={error}
dateRange={dateRange}
source={traceTableSource}
isSingleTrace={isSingleTrace}
/>
</ReactFlowProvider>
);
}

View file

@ -15,6 +15,7 @@ export type ServiceMapEdgeData = {
dateRange: [Date, Date];
source: TSource;
serviceName: string;
isSingleTrace?: boolean;
};
export default function ServiceMapEdge(
@ -26,8 +27,14 @@ export default function ServiceMapEdge(
return null;
}
const { totalRequests, errorPercentage, dateRange, serviceName, source } =
props.data;
const {
totalRequests,
errorPercentage,
dateRange,
serviceName,
source,
isSingleTrace,
} = props.data;
return (
<>
@ -44,6 +51,7 @@ export default function ServiceMapEdge(
source={source}
dateRange={dateRange}
serviceName={serviceName}
isSingleTrace={isSingleTrace}
/>
</EdgeToolbar>
</>

View file

@ -13,6 +13,7 @@ export type ServiceMapNodeData = ServiceAggregation & {
dateRange: [Date, Date];
source: TSource;
maxErrorPercentage: number;
isSingleTrace?: boolean;
};
export default function ServiceMapNode(
@ -28,6 +29,7 @@ export default function ServiceMapNode(
source,
dateRange,
maxErrorPercentage,
isSingleTrace,
} = data;
const { backgroundColor, borderColor } = getNodeColors(
@ -45,6 +47,7 @@ export default function ServiceMapNode(
source={source}
dateRange={dateRange}
serviceName={serviceName}
isSingleTrace={isSingleTrace}
/>
</NodeToolbar>
<div className={`${styles.serviceNode}`}>

View file

@ -0,0 +1,47 @@
import { Badge, Group, Stack, Text } from '@mantine/core';
import { useSource } from '@/source';
import ServiceMap from './ServiceMap';
interface ServiceMapSidePanelProps {
traceId: string;
dateRange: [Date, Date];
traceTableSourceId: string;
}
export default function ServiceMapSidePanel({
traceId,
dateRange,
traceTableSourceId,
}: ServiceMapSidePanelProps) {
const { data: traceTableSource } = useSource({ id: traceTableSourceId });
return (
<Stack w="100%">
<Group gap={0}>
<Text size="sm" c="gray.2" ps="sm">
Service Map
</Text>
<Badge
size="xs"
ms="xs"
color="gray.4"
autoContrast
radius="sm"
className="align-text-bottom"
>
Beta
</Badge>
</Group>
{traceTableSource ? (
<ServiceMap
traceTableSource={traceTableSource}
traceId={traceId}
dateRange={dateRange}
isSingleTrace
/>
) : null}
</Stack>
);
}

View file

@ -12,12 +12,14 @@ export default function ServiceMapTooltip({
source,
dateRange,
serviceName,
isSingleTrace,
}: {
totalRequests: number;
errorPercentage: number;
source: TSource;
dateRange: [Date, Date];
serviceName: string;
isSingleTrace?: boolean;
}) {
return (
<div className={styles.toolbar}>
@ -35,7 +37,8 @@ export default function ServiceMapTooltip({
}
className={styles.linkButton}
>
{formatApproximateNumber(totalRequests)} request
{isSingleTrace ? totalRequests : formatApproximateNumber(totalRequests)}{' '}
request
{totalRequests !== 1 ? 's' : ''}
</UnstyledButton>
{errorPercentage > 0 ? (