mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
5e440ab390
commit
a7e150c825
8 changed files with 139 additions and 35 deletions
5
.changeset/selfish-rings-sleep.md
Normal file
5
.changeset/selfish-rings-sleep.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Improve Service Maps
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue