mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add service map (beta) (#1319)
Closes HDX-2699 # Summary This PR adds a Service Map feature to HyperDX, based on (sampled) trace data. ## Demo https://github.com/user-attachments/assets/602e9b42-1586-4cb1-9c99-024c7ef9d2bb ## How the service map is constructed The service map is created by querying client-server (or producer-consumer) relationships from a Trace source. Two spans have a client-server/producer-consumer relationship if (a) they have the same trace ID and (b) the server/consumer's parent span ID is equal to the client/producer's span ID. This is accomplished via a self-join on the Trace table (the query can be found in `useServiceMap.ts`. To help keep this join performant, user's can set a sampling level as low as 1% and up to 100%. Lower sampling levels will result in fewer rows being joined, and thus a faster service map load. Sampling is done on `cityHash64(TraceId)` to ensure that either a trace is included in its entirety or not included at all.
This commit is contained in:
parent
24bf2b419d
commit
91e443f431
18 changed files with 2080 additions and 4 deletions
5
.changeset/itchy-grapes-fail.md
Normal file
5
.changeset/itchy-grapes-fail.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
feat: Add service maps (beta)
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-sql": "^6.7.0",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hyperdx/browser": "^0.21.1",
|
||||
"@hyperdx/common-utils": "^0.7.2",
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
"@uiw/codemirror-theme-atomone": "^4.23.3",
|
||||
"@uiw/codemirror-themes": "^4.23.3",
|
||||
"@uiw/react-codemirror": "^4.23.3",
|
||||
"@xyflow/react": "^12.9.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"chrono-node": "^2.7.8",
|
||||
"classnames": "^2.3.1",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import '@mantine/dropzone/styles.css';
|
|||
import '@styles/globals.css';
|
||||
import '@styles/app.scss';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
// Polyfill crypto.randomUUID for non-HTTPS environments
|
||||
if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
|
||||
|
|
|
|||
2
packages/app/pages/service-map.tsx
Normal file
2
packages/app/pages/service-map.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import DBServiceMapPage from '@/DBServiceMapPage';
|
||||
export default DBServiceMapPage;
|
||||
|
|
@ -4,6 +4,7 @@ import cx from 'classnames';
|
|||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Menu,
|
||||
|
|
@ -298,6 +299,7 @@ export const AppNavLink = ({
|
|||
href,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
isBeta,
|
||||
}: {
|
||||
className?: string;
|
||||
label: React.ReactNode;
|
||||
|
|
@ -305,6 +307,7 @@ export const AppNavLink = ({
|
|||
href: string;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
isBeta?: boolean;
|
||||
}) => {
|
||||
const { pathname, isCollapsed } = React.useContext(AppNavContext);
|
||||
|
||||
|
|
@ -324,7 +327,23 @@ export const AppNavLink = ({
|
|||
>
|
||||
<span>
|
||||
<i className={`bi ${iconName} pe-2 text-slate-300`} />{' '}
|
||||
{!isCollapsed && <span>{label}</span>}
|
||||
{!isCollapsed && (
|
||||
<span>
|
||||
{label}
|
||||
{isBeta && (
|
||||
<Badge
|
||||
size="xs"
|
||||
ms="xs"
|
||||
color="gray.4"
|
||||
autoContrast
|
||||
radius="sm"
|
||||
className="align-text-bottom"
|
||||
>
|
||||
Beta
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{!isCollapsed && onToggle && (
|
||||
|
|
|
|||
|
|
@ -730,6 +730,13 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
|
|||
iconName="bi-laptop"
|
||||
/>
|
||||
|
||||
<AppNavLink
|
||||
label="Service Map"
|
||||
href="/service-map"
|
||||
iconName="bi-diagram-2-fill"
|
||||
isBeta
|
||||
/>
|
||||
|
||||
<AppNavLink
|
||||
label="Dashboards"
|
||||
href="/dashboards"
|
||||
|
|
|
|||
151
packages/app/src/DBServiceMapPage.tsx
Normal file
151
packages/app/src/DBServiceMapPage.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box, Group, Slider, Text } from '@mantine/core';
|
||||
|
||||
import { withAppNav } from '@/layout';
|
||||
|
||||
import ServiceMap from './components/ServiceMap/ServiceMap';
|
||||
import SourceSchemaPreview from './components/SourceSchemaPreview';
|
||||
import { SourceSelectControlled } from './components/SourceSelect';
|
||||
import { TimePicker } from './components/TimePicker';
|
||||
import { useSources } from './source';
|
||||
import { parseTimeQuery, useNewTimeQuery } from './timeQuery';
|
||||
|
||||
// The % of requests sampled is 1 / sampling factor
|
||||
export const SAMPLING_FACTORS = [
|
||||
{
|
||||
value: 100,
|
||||
label: '1%',
|
||||
},
|
||||
{
|
||||
value: 20,
|
||||
label: '5%',
|
||||
},
|
||||
{
|
||||
value: 10,
|
||||
label: '10%',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '50%',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '100%',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_INTERVAL = 'Past 1h';
|
||||
const defaultTimeRange = parseTimeQuery(DEFAULT_INTERVAL, false) as [
|
||||
Date,
|
||||
Date,
|
||||
];
|
||||
|
||||
function DBServiceMapPage() {
|
||||
const { data: sources } = useSources();
|
||||
const [sourceId, setSourceId] = useQueryState('source');
|
||||
|
||||
const [displayedTimeInputValue, setDisplayedTimeInputValue] =
|
||||
useState(DEFAULT_INTERVAL);
|
||||
|
||||
const { searchedTimeRange, onSearch } = useNewTimeQuery({
|
||||
initialDisplayValue: DEFAULT_INTERVAL,
|
||||
initialTimeRange: defaultTimeRange,
|
||||
setDisplayedTimeInputValue,
|
||||
});
|
||||
|
||||
const defaultSource = sources?.find(
|
||||
source => source.kind === SourceKind.Trace,
|
||||
);
|
||||
const source =
|
||||
sourceId && sources
|
||||
? (sources.find(
|
||||
source => source.id === sourceId && source.kind === SourceKind.Trace,
|
||||
) ?? defaultSource)
|
||||
: defaultSource;
|
||||
|
||||
const { control, watch } = useForm({
|
||||
values: {
|
||||
source: source?.id,
|
||||
},
|
||||
});
|
||||
|
||||
watch((data, { name, type }) => {
|
||||
if (name === 'source' && type === 'change') {
|
||||
setSourceId(data.source ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
const [samplingFactor, setSamplingFactor] = useQueryState(
|
||||
'samplingFactor',
|
||||
parseAsInteger.withDefault(10),
|
||||
);
|
||||
const { label: samplingLabel = '' } =
|
||||
SAMPLING_FACTORS.find(factor => factor.value === samplingFactor) ?? {};
|
||||
|
||||
return source ? (
|
||||
<Box
|
||||
data-testid="service-map-page"
|
||||
p="sm"
|
||||
className="bg-hdx-dark"
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}
|
||||
>
|
||||
<Group mb="md" justify="space-between">
|
||||
<Group>
|
||||
<Text c="gray.4" size="xl">
|
||||
Service Map
|
||||
</Text>
|
||||
<SourceSelectControlled
|
||||
control={control}
|
||||
name="source"
|
||||
size="xs"
|
||||
allowedSourceKinds={[SourceKind.Trace]}
|
||||
sourceSchemaPreview={
|
||||
<SourceSchemaPreview source={source} variant="text" />
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Text c="gray.4" bg="inherit" size="sm">
|
||||
Sampling {samplingLabel}
|
||||
</Text>
|
||||
<div style={{ minWidth: '200px' }}>
|
||||
<Slider
|
||||
label={null}
|
||||
color="green"
|
||||
min={0}
|
||||
max={SAMPLING_FACTORS.length - 1}
|
||||
value={SAMPLING_FACTORS.findIndex(
|
||||
factor => factor.value === samplingFactor,
|
||||
)}
|
||||
onChange={v => setSamplingFactor(SAMPLING_FACTORS[v].value)}
|
||||
showLabelOnHover={false}
|
||||
/>
|
||||
</div>
|
||||
<TimePicker
|
||||
inputValue={displayedTimeInputValue}
|
||||
setInputValue={setDisplayedTimeInputValue}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<ServiceMap
|
||||
traceTableSource={source}
|
||||
dateRange={searchedTimeRange}
|
||||
samplingFactor={samplingFactor}
|
||||
/>
|
||||
</Box>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const DBServiceMapPageDynamic = dynamic(async () => DBServiceMapPage, {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
DBServiceMapPageDynamic.getLayout = withAppNav;
|
||||
|
||||
export default DBServiceMapPageDynamic;
|
||||
|
|
@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
|
|||
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import { SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
|
|
@ -18,6 +19,7 @@ import { DBTraceWaterfallChartContainer } from '@/components/DBTraceWaterfallCha
|
|||
import { useSource, useUpdateSource } from '@/source';
|
||||
import TabBar from '@/TabBar';
|
||||
|
||||
import ServiceMap from './ServiceMap/ServiceMap';
|
||||
import { RowDataPanel } from './DBRowDataPanel';
|
||||
import { RowOverviewPanel } from './DBRowOverviewPanel';
|
||||
import { SourceSelectControlled } from './SourceSelect';
|
||||
|
|
@ -207,6 +209,28 @@ export default function DBTracePanel({
|
|||
)}
|
||||
{traceSourceData != null && eventRowWhere != null && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 100px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
color: #111;
|
||||
|
||||
.linkButton {
|
||||
font-size: small;
|
||||
}
|
||||
}
|
||||
|
||||
.serviceNode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
263
packages/app/src/components/ServiceMap/ServiceMap.tsx
Normal file
263
packages/app/src/components/ServiceMap/ServiceMap.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box, Center, Code, Loader, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
applyEdgeChanges,
|
||||
applyNodeChanges,
|
||||
Controls,
|
||||
Edge,
|
||||
EdgeChange,
|
||||
EdgeTypes,
|
||||
Node,
|
||||
NodeChange,
|
||||
Position,
|
||||
ReactFlow,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import useServiceMap, { ServiceAggregation } from '@/hooks/useServiceMap';
|
||||
|
||||
import { SQLPreview } from '../ChartSQLPreview';
|
||||
|
||||
import ServiceMapEdge, { ServiceMapEdgeData } from './ServiceMapEdge';
|
||||
import ServiceMapNode, { ServiceMapNodeData } from './ServiceMapNode';
|
||||
|
||||
import styles from './ServiceMap.module.scss';
|
||||
|
||||
const nodeTypes = {
|
||||
service: ServiceMapNode,
|
||||
};
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
request: ServiceMapEdge,
|
||||
};
|
||||
|
||||
function getGraphLayout(nodes: Node[], edges: Edge[]): Node[] {
|
||||
const NODE_SIZE = 80;
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
dagreGraph.setGraph({ rankdir: 'LR' });
|
||||
|
||||
for (const node of nodes) {
|
||||
dagreGraph.setNode(node.id, { width: NODE_SIZE, height: NODE_SIZE / 2 });
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const newNodes: Node[] = nodes.map(node => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
const newNode = {
|
||||
...node,
|
||||
targetPosition: Position.Left,
|
||||
sourcePosition: Position.Right,
|
||||
position: {
|
||||
x: nodeWithPosition.x - NODE_SIZE / 2,
|
||||
y: nodeWithPosition.y - NODE_SIZE / 2,
|
||||
},
|
||||
};
|
||||
|
||||
return newNode;
|
||||
});
|
||||
|
||||
return newNodes;
|
||||
}
|
||||
|
||||
interface ServiceMapPresentationProps {
|
||||
services: Map<string, ServiceAggregation> | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
dateRange: [Date, Date];
|
||||
source: TSource;
|
||||
}
|
||||
|
||||
function ServiceMapPresentation({
|
||||
services,
|
||||
isLoading,
|
||||
error,
|
||||
dateRange,
|
||||
source,
|
||||
}: ServiceMapPresentationProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange<Node>[]) =>
|
||||
setNodes(nodesSnapshot => applyNodeChanges(changes, nodesSnapshot)),
|
||||
[],
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange<Edge>[]) =>
|
||||
setEdges(edgesSnapshot => applyEdgeChanges(changes, edgesSnapshot)),
|
||||
[],
|
||||
);
|
||||
|
||||
const maxErrorPercentage = useMemo(() => {
|
||||
let maxError = 0;
|
||||
for (const service of services?.values() ?? []) {
|
||||
maxError = Math.max(service.incomingRequests.errorPercentage, maxError);
|
||||
}
|
||||
return maxError;
|
||||
}, [services]);
|
||||
|
||||
useEffect(() => {
|
||||
const nodes: Node<ServiceMapNodeData>[] =
|
||||
Array.from(services?.values() ?? []).map((service, index) => ({
|
||||
id: service.serviceName,
|
||||
data: {
|
||||
...service,
|
||||
dateRange,
|
||||
source,
|
||||
maxErrorPercentage,
|
||||
},
|
||||
position: { x: index * 150, y: 100 },
|
||||
type: 'service',
|
||||
})) ?? [];
|
||||
|
||||
const edges: Edge<ServiceMapEdgeData>[] = Array.from(
|
||||
services?.values() ?? [],
|
||||
)
|
||||
.filter(service => service.incomingRequestsByClient.size > 0)
|
||||
.flatMap(
|
||||
({
|
||||
serviceName,
|
||||
incomingRequestsByClient: requestCountPerClientPerStatus,
|
||||
}) =>
|
||||
Array.from(requestCountPerClientPerStatus.entries()).map(
|
||||
([clientServiceName, { totalRequests, errorPercentage }]) => {
|
||||
return {
|
||||
id: `${serviceName}-${clientServiceName}`,
|
||||
source: clientServiceName,
|
||||
target: serviceName,
|
||||
animated: true,
|
||||
type: 'request',
|
||||
data: {
|
||||
totalRequests,
|
||||
errorPercentage,
|
||||
source,
|
||||
dateRange,
|
||||
serviceName,
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const nodeWithLayout = getGraphLayout(nodes, edges);
|
||||
|
||||
setNodes(nodeWithLayout);
|
||||
setEdges(edges);
|
||||
}, [services, dateRange, source, maxErrorPercentage]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center className={`${styles.graphContainer} h-100`}>
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box>
|
||||
<Text my="sm" size="sm">
|
||||
Error message:
|
||||
</Text>
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{error?.message}
|
||||
</Code>
|
||||
{error instanceof ClickHouseQueryError && (
|
||||
<Box mt="lg">
|
||||
<Text my="sm" size="sm">
|
||||
Original query:
|
||||
</Text>
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
<SQLPreview data={error.query} formatData />
|
||||
</Code>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<ReactFlow
|
||||
style={{ backgroundColor: 'inherit' }}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
colorMode="dark"
|
||||
// TODO: Financially support react-flow if possible
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceMapProps {
|
||||
traceId?: string;
|
||||
traceTableSource: TSource;
|
||||
dateRange: [Date, Date];
|
||||
samplingFactor?: number;
|
||||
}
|
||||
|
||||
export default function ServiceMap({
|
||||
traceId,
|
||||
traceTableSource,
|
||||
dateRange,
|
||||
samplingFactor = 1,
|
||||
}: ServiceMapProps) {
|
||||
const {
|
||||
isLoading,
|
||||
data: services,
|
||||
error,
|
||||
} = useServiceMap({
|
||||
traceId,
|
||||
source: traceTableSource,
|
||||
dateRange,
|
||||
samplingFactor,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Error loading service map',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<ServiceMapPresentation
|
||||
services={services}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
dateRange={dateRange}
|
||||
source={traceTableSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
packages/app/src/components/ServiceMap/ServiceMapEdge.tsx
Normal file
51
packages/app/src/components/ServiceMap/ServiceMapEdge.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
BaseEdge,
|
||||
Edge,
|
||||
EdgeProps,
|
||||
EdgeToolbar,
|
||||
getBezierPath,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import ServiceMapTooltip from './ServiceMapTooltip';
|
||||
|
||||
export type ServiceMapEdgeData = {
|
||||
totalRequests: number;
|
||||
errorPercentage: number;
|
||||
dateRange: [Date, Date];
|
||||
source: TSource;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
export default function ServiceMapEdge(
|
||||
props: EdgeProps<Edge<ServiceMapEdgeData>>,
|
||||
) {
|
||||
const [edgePath, centerX, centerY] = getBezierPath(props);
|
||||
|
||||
if (!props.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { totalRequests, errorPercentage, dateRange, serviceName, source } =
|
||||
props.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={props.id} path={edgePath} />
|
||||
<EdgeToolbar
|
||||
edgeId={props.id}
|
||||
x={centerX}
|
||||
y={centerY}
|
||||
isVisible={props.selected}
|
||||
>
|
||||
<ServiceMapTooltip
|
||||
totalRequests={totalRequests}
|
||||
errorPercentage={errorPercentage}
|
||||
source={source}
|
||||
dateRange={dateRange}
|
||||
serviceName={serviceName}
|
||||
/>
|
||||
</EdgeToolbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
packages/app/src/components/ServiceMap/ServiceMapNode.tsx
Normal file
78
packages/app/src/components/ServiceMap/ServiceMapNode.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { Text } from '@mantine/core';
|
||||
import { Handle, Node, NodeProps, NodeToolbar, Position } from '@xyflow/react';
|
||||
|
||||
import { ServiceAggregation } from '@/hooks/useServiceMap';
|
||||
|
||||
import ServiceMapTooltip from './ServiceMapTooltip';
|
||||
import { getNodeColors } from './utils';
|
||||
|
||||
import styles from './ServiceMap.module.scss';
|
||||
|
||||
export type ServiceMapNodeData = ServiceAggregation & {
|
||||
dateRange: [Date, Date];
|
||||
source: TSource;
|
||||
maxErrorPercentage: number;
|
||||
};
|
||||
|
||||
export default function ServiceMapNode(
|
||||
props: NodeProps<Node<ServiceMapNodeData, 'service'>>,
|
||||
) {
|
||||
const { data } = props;
|
||||
const {
|
||||
serviceName,
|
||||
incomingRequests: {
|
||||
totalRequests: totalIncomingRequestCount,
|
||||
errorPercentage,
|
||||
},
|
||||
source,
|
||||
dateRange,
|
||||
maxErrorPercentage,
|
||||
} = data;
|
||||
|
||||
const { backgroundColor, borderColor } = getNodeColors(
|
||||
errorPercentage,
|
||||
maxErrorPercentage,
|
||||
props.selected,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeToolbar position={Position.Top} align="center">
|
||||
<ServiceMapTooltip
|
||||
errorPercentage={errorPercentage}
|
||||
totalRequests={totalIncomingRequestCount}
|
||||
source={source}
|
||||
dateRange={dateRange}
|
||||
serviceName={serviceName}
|
||||
/>
|
||||
</NodeToolbar>
|
||||
<div className={`${styles.serviceNode}`}>
|
||||
<div className={styles.body}>
|
||||
<div className="position-relative">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
style={{ visibility: 'hidden', marginLeft: 3 }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={styles.circle}
|
||||
style={{
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
}}
|
||||
/>
|
||||
<div className="position-relative" style={{ marginLeft: -3 }}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
style={{ visibility: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xxs">{serviceName}</Text>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx
Normal file
70
packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import SqlString from 'sqlstring';
|
||||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { UnstyledButton } from '@mantine/core';
|
||||
|
||||
import { formatApproximateNumber, navigateToTraceSearch } from './utils';
|
||||
|
||||
import styles from './ServiceMap.module.scss';
|
||||
|
||||
export default function ServiceMapTooltip({
|
||||
totalRequests,
|
||||
errorPercentage,
|
||||
source,
|
||||
dateRange,
|
||||
serviceName,
|
||||
}: {
|
||||
totalRequests: number;
|
||||
errorPercentage: number;
|
||||
source: TSource;
|
||||
dateRange: [Date, Date];
|
||||
serviceName: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<UnstyledButton
|
||||
onClick={() =>
|
||||
navigateToTraceSearch({
|
||||
dateRange,
|
||||
source,
|
||||
where: SqlString.format("? = ? AND ? IN ('Server', 'Consumer')", [
|
||||
SqlString.raw(source.serviceNameExpression ?? 'ServiceName'),
|
||||
serviceName,
|
||||
SqlString.raw(source.spanKindExpression ?? 'SpanKind'),
|
||||
]),
|
||||
})
|
||||
}
|
||||
className={styles.linkButton}
|
||||
>
|
||||
{formatApproximateNumber(totalRequests)} request
|
||||
{totalRequests !== 1 ? 's' : ''}
|
||||
</UnstyledButton>
|
||||
{errorPercentage > 0 ? (
|
||||
<>
|
||||
{', '}
|
||||
<UnstyledButton
|
||||
onClick={() =>
|
||||
navigateToTraceSearch({
|
||||
dateRange,
|
||||
source,
|
||||
where: SqlString.format(
|
||||
"? = ? AND ? IN ('Server', 'Consumer') AND ? = 'Error'",
|
||||
[
|
||||
SqlString.raw(
|
||||
source.serviceNameExpression ?? 'ServiceName',
|
||||
),
|
||||
serviceName,
|
||||
SqlString.raw(source.spanKindExpression ?? 'SpanKind'),
|
||||
SqlString.raw(source.statusCodeExpression ?? 'StatusCode'),
|
||||
],
|
||||
),
|
||||
})
|
||||
}
|
||||
className={styles.linkButton}
|
||||
>
|
||||
{errorPercentage.toFixed(2)}% error
|
||||
</UnstyledButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
packages/app/src/components/ServiceMap/__tests__/utils.test.ts
Normal file
340
packages/app/src/components/ServiceMap/__tests__/utils.test.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import router from 'next/router';
|
||||
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
import {
|
||||
formatApproximateNumber,
|
||||
getNodeColors,
|
||||
navigateToTraceSearch,
|
||||
} from '../utils';
|
||||
|
||||
// Mock next/router
|
||||
jest.mock('next/router', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('navigateToTraceSearch', () => {
|
||||
const mockSource: TSource = {
|
||||
id: 'test-source-id',
|
||||
name: 'Test Source',
|
||||
from: {
|
||||
tableName: 'test_table',
|
||||
databaseName: 'test_db',
|
||||
},
|
||||
timestampValueExpression: 'timestamp',
|
||||
connection: 'test-connection',
|
||||
kind: SourceKind.Trace,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should navigate to search page with correct query parameters', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2024-01-15T10:00:00.000Z'),
|
||||
new Date('2024-01-15T11:00:00.000Z'),
|
||||
];
|
||||
|
||||
navigateToTraceSearch({
|
||||
dateRange,
|
||||
source: mockSource,
|
||||
where: "service_name = 'my-service'",
|
||||
});
|
||||
|
||||
expect(router.push).toHaveBeenCalledTimes(1);
|
||||
|
||||
const callArg = (router.push as jest.Mock).mock.calls[0][0];
|
||||
expect(callArg).toContain('/search?');
|
||||
|
||||
// Parse query params
|
||||
const url = new URL(callArg, 'http://localhost');
|
||||
const params = url.searchParams;
|
||||
|
||||
expect(params.get('isLive')).toBe('false');
|
||||
expect(params.get('source')).toBe('test-source-id');
|
||||
expect(params.get('where')).toBe("service_name = 'my-service'");
|
||||
expect(params.get('whereLanguage')).toBe('sql');
|
||||
expect(params.get('from')).toBe('1705312800000');
|
||||
expect(params.get('to')).toBe('1705316400000');
|
||||
});
|
||||
|
||||
it('should handle different date ranges', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2023-12-01T00:00:00.000Z'),
|
||||
new Date('2023-12-31T23:59:59.999Z'),
|
||||
];
|
||||
|
||||
navigateToTraceSearch({
|
||||
dateRange,
|
||||
source: mockSource,
|
||||
where: 'status_code = 500',
|
||||
});
|
||||
|
||||
const callArg = (router.push as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(callArg, 'http://localhost');
|
||||
const params = url.searchParams;
|
||||
|
||||
expect(params.get('from')).toBe('1701388800000');
|
||||
expect(params.get('to')).toBe('1704067199999');
|
||||
});
|
||||
|
||||
it('should handle complex where clauses', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2024-01-01T00:00:00.000Z'),
|
||||
new Date('2024-01-02T00:00:00.000Z'),
|
||||
];
|
||||
|
||||
const complexWhere =
|
||||
"service_name = 'my-service' AND status_code >= 400 AND span_kind = 'server'";
|
||||
|
||||
navigateToTraceSearch({
|
||||
dateRange,
|
||||
source: mockSource,
|
||||
where: complexWhere,
|
||||
});
|
||||
|
||||
const callArg = (router.push as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(callArg, 'http://localhost');
|
||||
const params = url.searchParams;
|
||||
|
||||
expect(params.get('where')).toBe(complexWhere);
|
||||
});
|
||||
|
||||
it('should handle special characters in where clause', () => {
|
||||
const dateRange: [Date, Date] = [
|
||||
new Date('2024-01-01T00:00:00.000Z'),
|
||||
new Date('2024-01-02T00:00:00.000Z'),
|
||||
];
|
||||
|
||||
const whereWithSpecialChars = "service_name = 'test&service=value'";
|
||||
|
||||
navigateToTraceSearch({
|
||||
dateRange,
|
||||
source: mockSource,
|
||||
where: whereWithSpecialChars,
|
||||
});
|
||||
|
||||
const callArg = (router.push as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(callArg, 'http://localhost');
|
||||
const params = url.searchParams;
|
||||
|
||||
// URL encoding should preserve the where clause
|
||||
expect(params.get('where')).toBe(whereWithSpecialChars);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatApproximateNumber', () => {
|
||||
describe('numbers less than 1000', () => {
|
||||
it('should format zero correctly', () => {
|
||||
expect(formatApproximateNumber(0)).toBe('~0');
|
||||
});
|
||||
|
||||
it('should format small positive numbers correctly', () => {
|
||||
expect(formatApproximateNumber(1)).toBe('~1');
|
||||
expect(formatApproximateNumber(42)).toBe('~42');
|
||||
expect(formatApproximateNumber(999)).toBe('~999');
|
||||
});
|
||||
|
||||
it('should format decimal numbers correctly', () => {
|
||||
expect(formatApproximateNumber(1.5)).toBe('~1.5');
|
||||
expect(formatApproximateNumber(42.7)).toBe('~42.7');
|
||||
expect(formatApproximateNumber(999.99)).toBe('~999.99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('thousands (1K - 999K)', () => {
|
||||
it('should format exact thousands correctly', () => {
|
||||
expect(formatApproximateNumber(1000)).toBe('~1k');
|
||||
expect(formatApproximateNumber(5000)).toBe('~5k');
|
||||
expect(formatApproximateNumber(10000)).toBe('~10k');
|
||||
});
|
||||
|
||||
it('should round to nearest thousand', () => {
|
||||
expect(formatApproximateNumber(1234)).toBe('~1k');
|
||||
expect(formatApproximateNumber(1500)).toBe('~2k');
|
||||
expect(formatApproximateNumber(1499)).toBe('~1k');
|
||||
expect(formatApproximateNumber(9876)).toBe('~10k');
|
||||
});
|
||||
|
||||
it('should handle values near million boundary', () => {
|
||||
expect(formatApproximateNumber(999000)).toBe('~999k');
|
||||
expect(formatApproximateNumber(999499)).toBe('~999k');
|
||||
expect(formatApproximateNumber(999500)).toBe('~1000k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('millions (1M - 999M)', () => {
|
||||
it('should format exact millions correctly', () => {
|
||||
expect(formatApproximateNumber(1_000_000)).toBe('~1M');
|
||||
expect(formatApproximateNumber(5_000_000)).toBe('~5M');
|
||||
expect(formatApproximateNumber(10_000_000)).toBe('~10M');
|
||||
});
|
||||
|
||||
it('should round to nearest million', () => {
|
||||
expect(formatApproximateNumber(1_234_567)).toBe('~1M');
|
||||
expect(formatApproximateNumber(1_500_000)).toBe('~2M');
|
||||
expect(formatApproximateNumber(1_499_999)).toBe('~1M');
|
||||
expect(formatApproximateNumber(9_876_543)).toBe('~10M');
|
||||
});
|
||||
|
||||
it('should handle values near billion boundary', () => {
|
||||
expect(formatApproximateNumber(999_000_000)).toBe('~999M');
|
||||
expect(formatApproximateNumber(999_499_999)).toBe('~999M');
|
||||
expect(formatApproximateNumber(999_500_000)).toBe('~1000M');
|
||||
});
|
||||
});
|
||||
|
||||
describe('billions (1B+)', () => {
|
||||
it('should format exact billions correctly', () => {
|
||||
expect(formatApproximateNumber(1_000_000_000)).toBe('~1B');
|
||||
expect(formatApproximateNumber(5_000_000_000)).toBe('~5B');
|
||||
expect(formatApproximateNumber(10_000_000_000)).toBe('~10B');
|
||||
});
|
||||
|
||||
it('should round to nearest billion', () => {
|
||||
expect(formatApproximateNumber(1_234_567_890)).toBe('~1B');
|
||||
expect(formatApproximateNumber(1_500_000_000)).toBe('~2B');
|
||||
expect(formatApproximateNumber(1_499_999_999)).toBe('~1B');
|
||||
expect(formatApproximateNumber(9_876_543_210)).toBe('~10B');
|
||||
});
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
expect(formatApproximateNumber(999_000_000_000)).toBe('~999B');
|
||||
expect(formatApproximateNumber(1_000_000_000_000)).toBe('~1000B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle boundary values precisely', () => {
|
||||
expect(formatApproximateNumber(999.99)).toBe('~999.99');
|
||||
expect(formatApproximateNumber(1000.01)).toBe('~1k');
|
||||
expect(formatApproximateNumber(999_999.99)).toBe('~1000k');
|
||||
expect(formatApproximateNumber(1_000_000.01)).toBe('~1M');
|
||||
expect(formatApproximateNumber(999_999_999.99)).toBe('~1000M');
|
||||
expect(formatApproximateNumber(1_000_000_000.01)).toBe('~1B');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeColors', () => {
|
||||
describe('background color calculation', () => {
|
||||
it('should return light background when error percent is 0', () => {
|
||||
const colors = getNodeColors(0, 20, false);
|
||||
expect(colors.backgroundColor).toBe('hsl(0 0% 80%)');
|
||||
});
|
||||
|
||||
it('should calculate background color based on error percentage', () => {
|
||||
const colors = getNodeColors(10, 20, false);
|
||||
// (10 / 20) * 100 = 50% saturation
|
||||
expect(colors.backgroundColor).toBe('hsl(0 50% 80%)');
|
||||
});
|
||||
|
||||
it('should use full saturation when error percent equals max', () => {
|
||||
const colors = getNodeColors(20, 20, false);
|
||||
// (20 / 20) * 100 = 100% saturation
|
||||
expect(colors.backgroundColor).toBe('hsl(0 100% 80%)');
|
||||
});
|
||||
|
||||
it('should cap at max error rate even if actual error is higher', () => {
|
||||
const colors = getNodeColors(30, 20, false);
|
||||
// Math.min(20, 30) = 20, (20 / 20) * 100 = 100% saturation
|
||||
expect(colors.backgroundColor).toBe('hsl(0 100% 80%)');
|
||||
});
|
||||
|
||||
it('should handle very small error percentages', () => {
|
||||
const colors = getNodeColors(0.1, 20, false);
|
||||
// (0.1 / 20) * 100 = 0.5% saturation
|
||||
expect(colors.backgroundColor).toBe('hsl(0 0.5% 80%)');
|
||||
});
|
||||
|
||||
it('should handle when maxErrorPercent is 0', () => {
|
||||
// This would cause division by zero, but Math results in Infinity
|
||||
const colors = getNodeColors(5, 0, false);
|
||||
expect(colors.backgroundColor).toContain('hsl(0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('border color calculation', () => {
|
||||
it('should return white border when node is selected', () => {
|
||||
const colors = getNodeColors(10, 20, true);
|
||||
expect(colors.borderColor).toBe('white');
|
||||
});
|
||||
|
||||
it('should return calculated border color when not selected', () => {
|
||||
const colors = getNodeColors(10, 20, false);
|
||||
// (10 / 20) * 100 = 50% saturation with 40% lightness
|
||||
expect(colors.borderColor).toBe('hsl(0 50% 40%)');
|
||||
});
|
||||
|
||||
it('should return dark border for high error rates when not selected', () => {
|
||||
const colors = getNodeColors(20, 20, false);
|
||||
expect(colors.borderColor).toBe('hsl(0 100% 40%)');
|
||||
});
|
||||
|
||||
it('should return light border for zero errors when not selected', () => {
|
||||
const colors = getNodeColors(0, 20, false);
|
||||
expect(colors.borderColor).toBe('hsl(0 0% 40%)');
|
||||
});
|
||||
|
||||
it('should cap border color saturation like background', () => {
|
||||
const colors = getNodeColors(30, 20, false);
|
||||
// Should cap at 20% error rate
|
||||
expect(colors.borderColor).toBe('hsl(0 100% 40%)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected state', () => {
|
||||
it('should always use white border when selected regardless of error rate', () => {
|
||||
expect(getNodeColors(0, 20, true).borderColor).toBe('white');
|
||||
expect(getNodeColors(5, 20, true).borderColor).toBe('white');
|
||||
expect(getNodeColors(10, 20, true).borderColor).toBe('white');
|
||||
expect(getNodeColors(20, 20, true).borderColor).toBe('white');
|
||||
expect(getNodeColors(30, 20, true).borderColor).toBe('white');
|
||||
});
|
||||
|
||||
it('should still calculate background color correctly when selected', () => {
|
||||
const colors = getNodeColors(10, 20, true);
|
||||
expect(colors.backgroundColor).toBe('hsl(0 50% 80%)');
|
||||
expect(colors.borderColor).toBe('white');
|
||||
});
|
||||
});
|
||||
|
||||
describe('various error percentage scenarios', () => {
|
||||
it('should handle low error rates', () => {
|
||||
const colors = getNodeColors(1, 20, false);
|
||||
expect(colors.backgroundColor).toBe('hsl(0 5% 80%)');
|
||||
expect(colors.borderColor).toBe('hsl(0 5% 40%)');
|
||||
});
|
||||
|
||||
it('should handle medium error rates', () => {
|
||||
const colors = getNodeColors(10, 20, false);
|
||||
expect(colors.backgroundColor).toBe('hsl(0 50% 80%)');
|
||||
expect(colors.borderColor).toBe('hsl(0 50% 40%)');
|
||||
});
|
||||
|
||||
it('should handle high error rates', () => {
|
||||
const colors = getNodeColors(18, 20, false);
|
||||
expect(colors.backgroundColor).toBe('hsl(0 90% 80%)');
|
||||
expect(colors.borderColor).toBe('hsl(0 90% 40%)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('return value structure', () => {
|
||||
it('should return an object with backgroundColor and borderColor', () => {
|
||||
const colors = getNodeColors(10, 20, false);
|
||||
expect(colors).toHaveProperty('backgroundColor');
|
||||
expect(colors).toHaveProperty('borderColor');
|
||||
expect(typeof colors.backgroundColor).toBe('string');
|
||||
expect(typeof colors.borderColor).toBe('string');
|
||||
});
|
||||
|
||||
it('should return different objects for different inputs', () => {
|
||||
const colors1 = getNodeColors(5, 20, false);
|
||||
const colors2 = getNodeColors(10, 20, false);
|
||||
expect(colors1).not.toEqual(colors2);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
packages/app/src/components/ServiceMap/utils.ts
Normal file
62
packages/app/src/components/ServiceMap/utils.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import router from 'next/router';
|
||||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
export function navigateToTraceSearch({
|
||||
dateRange,
|
||||
source,
|
||||
where,
|
||||
}: {
|
||||
dateRange: [Date, Date];
|
||||
source: TSource;
|
||||
where: string;
|
||||
}) {
|
||||
const from = dateRange[0].getTime().toString();
|
||||
const to = dateRange[1].getTime().toString();
|
||||
const query = new URLSearchParams({
|
||||
isLive: 'false',
|
||||
source: source?.id,
|
||||
where,
|
||||
whereLanguage: 'sql',
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
router.push(`/search?${query.toString()}`);
|
||||
}
|
||||
|
||||
export function formatApproximateNumber(num: number): string {
|
||||
if (num < 1000) {
|
||||
return `~${num.toString()}`;
|
||||
}
|
||||
|
||||
if (num < 1_000_000) {
|
||||
const thousands = num / 1000;
|
||||
return `~${Math.round(thousands)}k`;
|
||||
}
|
||||
|
||||
if (num < 1_000_000_000) {
|
||||
const millions = num / 1_000_000;
|
||||
return `~${Math.round(millions)}M`;
|
||||
}
|
||||
|
||||
const billions = num / 1_000_000_000;
|
||||
return `~${Math.round(billions)}B`;
|
||||
}
|
||||
|
||||
export function getNodeColors(
|
||||
errorPercent: number,
|
||||
maxErrorPercent: number,
|
||||
isSelected: boolean,
|
||||
) {
|
||||
const saturation =
|
||||
maxErrorPercent > 0
|
||||
? (Math.min(errorPercent, maxErrorPercent) / maxErrorPercent) * 100
|
||||
: 0;
|
||||
const backgroundColor = `hsl(0 ${saturation}% 80%)`;
|
||||
const borderColor = isSelected ? 'white' : `hsl(0 ${saturation}% 40%)`;
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
};
|
||||
}
|
||||
484
packages/app/src/hooks/__tests__/useServiceMap.test.ts
Normal file
484
packages/app/src/hooks/__tests__/useServiceMap.test.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import { aggregateServiceMapData, SpanAggregationRow } from '../useServiceMap';
|
||||
|
||||
describe('aggregateServiceMapData', () => {
|
||||
describe('basic aggregation', () => {
|
||||
it('should return empty map for empty input', () => {
|
||||
const result = aggregateServiceMapData([]);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should aggregate single service with single status', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has('api-service')).toBe(true);
|
||||
|
||||
const service = result.get('api-service')!;
|
||||
expect(service.serviceName).toBe('api-service');
|
||||
expect(service.incomingRequests.totalRequests).toBe(100);
|
||||
expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(100);
|
||||
expect(service.incomingRequests.errorPercentage).toBe(0);
|
||||
expect(service.incomingRequestsByClient.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should aggregate multiple rows for same service and status', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 100,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 50,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
const service = result.get('api-service')!;
|
||||
expect(service.incomingRequests.totalRequests).toBe(150);
|
||||
expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(150);
|
||||
});
|
||||
|
||||
it('should aggregate multiple status codes for same service', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 100,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
requestCount: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
const service = result.get('api-service')!;
|
||||
expect(service.incomingRequests.totalRequests).toBe(110);
|
||||
expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(100);
|
||||
expect(service.incomingRequests.requestCountByStatus.get('Error')).toBe(
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it('should aggregate multiple services', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 100,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'db-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 200,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.has('api-service')).toBe(true);
|
||||
expect(result.has('db-service')).toBe(true);
|
||||
expect(result.get('api-service')!.incomingRequests.totalRequests).toBe(
|
||||
100,
|
||||
);
|
||||
expect(result.get('db-service')!.incomingRequests.totalRequests).toBe(
|
||||
200,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error percentage calculation', () => {
|
||||
it('should calculate error percentage correctly', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 70,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Unset',
|
||||
requestCount: 20,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
requestCount: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
|
||||
expect(service.incomingRequests.totalRequests).toBe(100);
|
||||
expect(service.incomingRequests.errorPercentage).toBe(10);
|
||||
});
|
||||
|
||||
it('should calculate 0% error when no errors', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
|
||||
expect(service.incomingRequests.errorPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate 100% error when all errors', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
|
||||
expect(service.incomingRequests.errorPercentage).toBe(100);
|
||||
});
|
||||
|
||||
it('should calculate precise error percentages', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 97,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
requestCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
|
||||
expect(service.incomingRequests.errorPercentage).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle 0 total requests without division by zero', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'client-service',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
// client-service is added but has no incoming requests
|
||||
const clientService = result.get('client-service')!;
|
||||
expect(clientService.incomingRequests.totalRequests).toBe(0);
|
||||
expect(clientService.incomingRequests.errorPercentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client service aggregation', () => {
|
||||
it('should aggregate requests by client service', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'web-service',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.has('api-service')).toBe(true);
|
||||
expect(result.has('web-service')).toBe(true);
|
||||
|
||||
const service = result.get('api-service')!;
|
||||
expect(service.incomingRequests.totalRequests).toBe(100);
|
||||
expect(service.incomingRequestsByClient.size).toBe(1);
|
||||
expect(service.incomingRequestsByClient.has('web-service')).toBe(true);
|
||||
|
||||
const clientStats = service.incomingRequestsByClient.get('web-service')!;
|
||||
expect(clientStats.totalRequests).toBe(100);
|
||||
expect(clientStats.requestCountByStatus.get('Ok')).toBe(100);
|
||||
});
|
||||
|
||||
it('should aggregate multiple clients calling same service', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'web-service',
|
||||
requestCount: 100,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'mobile-service',
|
||||
requestCount: 50,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.size).toBe(3); // api-service, web-service, mobile-service
|
||||
const service = result.get('api-service')!;
|
||||
expect(service.incomingRequests.totalRequests).toBe(150);
|
||||
expect(service.incomingRequestsByClient.size).toBe(2);
|
||||
|
||||
const webStats = service.incomingRequestsByClient.get('web-service')!;
|
||||
expect(webStats.totalRequests).toBe(100);
|
||||
|
||||
const mobileStats =
|
||||
service.incomingRequestsByClient.get('mobile-service')!;
|
||||
expect(mobileStats.totalRequests).toBe(50);
|
||||
});
|
||||
|
||||
it('should aggregate multiple requests from same client with different status codes', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'web-service',
|
||||
requestCount: 90,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
clientServiceName: 'web-service',
|
||||
requestCount: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
const clientStats = service.incomingRequestsByClient.get('web-service')!;
|
||||
|
||||
expect(clientStats.totalRequests).toBe(100);
|
||||
expect(clientStats.requestCountByStatus.get('Ok')).toBe(90);
|
||||
expect(clientStats.requestCountByStatus.get('Error')).toBe(10);
|
||||
expect(clientStats.errorPercentage).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle mix of requests with and without client service names', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'web-service',
|
||||
requestCount: 100,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 50, // No client service (uninstrumented)
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
const service = result.get('api-service')!;
|
||||
expect(service.incomingRequests.totalRequests).toBe(150);
|
||||
expect(service.incomingRequestsByClient.size).toBe(1);
|
||||
expect(service.incomingRequestsByClient.has('web-service')).toBe(true);
|
||||
});
|
||||
|
||||
it('should create client service entry even if client has no incoming requests', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'web-service',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.has('web-service')).toBe(true);
|
||||
const clientService = result.get('web-service')!;
|
||||
expect(clientService.serviceName).toBe('web-service');
|
||||
expect(clientService.incomingRequests.totalRequests).toBe(0);
|
||||
expect(clientService.incomingRequestsByClient.size).toBe(0);
|
||||
expect(clientService.incomingRequests.errorPercentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle service chain (A -> B -> C)', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'service-b',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'service-a',
|
||||
requestCount: 100,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'service-c',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'service-b',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
|
||||
expect(result.size).toBe(3);
|
||||
|
||||
// Service A (no incoming requests)
|
||||
const serviceA = result.get('service-a')!;
|
||||
expect(serviceA.incomingRequests.totalRequests).toBe(0);
|
||||
|
||||
// Service B (receives from A, calls C)
|
||||
const serviceB = result.get('service-b')!;
|
||||
expect(serviceB.incomingRequests.totalRequests).toBe(100);
|
||||
expect(serviceB.incomingRequestsByClient.has('service-a')).toBe(true);
|
||||
|
||||
// Service C (receives from B)
|
||||
const serviceC = result.get('service-c')!;
|
||||
expect(serviceC.incomingRequests.totalRequests).toBe(100);
|
||||
expect(serviceC.incomingRequestsByClient.has('service-b')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple clients with different error rates', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
// Client 1: 10% error rate
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'client-1',
|
||||
requestCount: 90,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
clientServiceName: 'client-1',
|
||||
requestCount: 10,
|
||||
},
|
||||
// Client 2: 50% error rate
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
clientServiceName: 'client-2',
|
||||
requestCount: 50,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
clientServiceName: 'client-2',
|
||||
requestCount: 50,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
|
||||
// Overall error rate: 60 errors out of 200 = 30%
|
||||
expect(service.incomingRequests.totalRequests).toBe(200);
|
||||
expect(service.incomingRequests.errorPercentage).toBe(30);
|
||||
|
||||
// Client 1: 10% error rate
|
||||
const client1Stats = service.incomingRequestsByClient.get('client-1')!;
|
||||
expect(client1Stats.errorPercentage).toBe(10);
|
||||
|
||||
// Client 2: 50% error rate
|
||||
const client2Stats = service.incomingRequestsByClient.get('client-2')!;
|
||||
expect(client2Stats.errorPercentage).toBe(50);
|
||||
});
|
||||
|
||||
it('should handle large request counts', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 1_000_000,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
requestCount: 1_000,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
|
||||
expect(service.incomingRequests.totalRequests).toBe(1_001_000);
|
||||
expect(service.incomingRequests.errorPercentage).toBeCloseTo(0.0999, 4);
|
||||
});
|
||||
|
||||
it('should handle different status code strings', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 50,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Error',
|
||||
requestCount: 10,
|
||||
},
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Unset',
|
||||
requestCount: 40,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateServiceMapData(data);
|
||||
const service = result.get('api-service')!;
|
||||
|
||||
expect(service.incomingRequests.totalRequests).toBe(100);
|
||||
expect(service.incomingRequests.requestCountByStatus.get('Ok')).toBe(50);
|
||||
expect(service.incomingRequests.requestCountByStatus.get('Error')).toBe(
|
||||
10,
|
||||
);
|
||||
expect(service.incomingRequests.requestCountByStatus.get('Unset')).toBe(
|
||||
40,
|
||||
);
|
||||
expect(service.incomingRequests.errorPercentage).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data structure integrity', () => {
|
||||
it('should not mutate input data', () => {
|
||||
const data: SpanAggregationRow[] = [
|
||||
{
|
||||
serverServiceName: 'api-service',
|
||||
serverStatusCode: 'Ok',
|
||||
requestCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const originalData = JSON.parse(JSON.stringify(data));
|
||||
aggregateServiceMapData(data);
|
||||
|
||||
expect(data).toEqual(originalData);
|
||||
});
|
||||
});
|
||||
});
|
||||
296
packages/app/src/hooks/useServiceMap.tsx
Normal file
296
packages/app/src/hooks/useServiceMap.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import SqlString from 'sqlstring';
|
||||
import { chSql } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
|
||||
import { TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useClickhouseClient } from '@/clickhouse';
|
||||
|
||||
import { useMetadataWithSettings } from './useMetadata';
|
||||
|
||||
export type SpanAggregationRow = {
|
||||
serverServiceName: string;
|
||||
serverStatusCode: string;
|
||||
requestCount: number;
|
||||
clientServiceName?: string;
|
||||
};
|
||||
|
||||
async function getServiceMapQuery({
|
||||
source,
|
||||
dateRange,
|
||||
traceId,
|
||||
metadata,
|
||||
samplingFactor,
|
||||
}: {
|
||||
source: TSource;
|
||||
dateRange: [Date, Date];
|
||||
traceId?: string;
|
||||
metadata: Metadata;
|
||||
samplingFactor: number;
|
||||
}) {
|
||||
// Don't sample if we're looking for a specific trace
|
||||
const effectiveSamplingLevel = traceId ? 1 : samplingFactor;
|
||||
|
||||
const baseCTEConfig = {
|
||||
from: source.from,
|
||||
connection: source.connection,
|
||||
dateRange,
|
||||
timestampValueExpression: source.timestampValueExpression,
|
||||
filters: [
|
||||
// Sample a subset of traces, for performance in the following join
|
||||
{
|
||||
type: 'sql' as const,
|
||||
condition: `cityHash64(${source.traceIdExpression}) % ${effectiveSamplingLevel} = 0`,
|
||||
},
|
||||
// Optionally filter for a specific trace ID
|
||||
...(traceId
|
||||
? [
|
||||
{
|
||||
type: 'sql' as const,
|
||||
condition: SqlString.format('?? = ?', [
|
||||
source.traceIdExpression,
|
||||
traceId,
|
||||
]),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
select: [
|
||||
{
|
||||
valueExpression: source.traceIdExpression ?? 'TraceId',
|
||||
alias: 'traceId',
|
||||
},
|
||||
{
|
||||
valueExpression: source.spanIdExpression ?? 'SpanId',
|
||||
alias: 'spanId',
|
||||
},
|
||||
{
|
||||
valueExpression: source.serviceNameExpression ?? 'ServiceName',
|
||||
alias: 'serviceName',
|
||||
},
|
||||
{
|
||||
valueExpression: source.parentSpanIdExpression ?? 'ParentSpanId',
|
||||
alias: 'parentSpanId',
|
||||
},
|
||||
{
|
||||
valueExpression: source.statusCodeExpression ?? 'StatusCode',
|
||||
alias: 'statusCode',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [serverCTE, clientCTE] = await Promise.all([
|
||||
renderChartConfig(
|
||||
{
|
||||
...baseCTEConfig,
|
||||
filters: [
|
||||
...baseCTEConfig.filters,
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${source.spanKindExpression} IN ('Server', 'Consumer')`,
|
||||
},
|
||||
],
|
||||
where: '',
|
||||
},
|
||||
metadata,
|
||||
),
|
||||
renderChartConfig(
|
||||
{
|
||||
...baseCTEConfig,
|
||||
filters: [
|
||||
...baseCTEConfig.filters,
|
||||
{
|
||||
type: 'sql',
|
||||
condition: `${source.spanKindExpression} IN ('Client', 'Producer')`,
|
||||
},
|
||||
],
|
||||
where: '',
|
||||
},
|
||||
metadata,
|
||||
),
|
||||
]);
|
||||
|
||||
// Left join to support services which receive requests from clients that are not instrumented.
|
||||
// Ordering helps ensure stable graph layout.
|
||||
return chSql`
|
||||
WITH
|
||||
ServerSpans AS (${serverCTE}),
|
||||
ClientSpans AS (${clientCTE})
|
||||
SELECT
|
||||
ServerSpans.serviceName AS serverServiceName,
|
||||
ServerSpans.statusCode AS serverStatusCode,
|
||||
ClientSpans.serviceName AS clientServiceName,
|
||||
count(*) * ${{ Int64: effectiveSamplingLevel }} as requestCount
|
||||
FROM ServerSpans
|
||||
LEFT JOIN ClientSpans
|
||||
ON ServerSpans.traceId = ClientSpans.traceId
|
||||
AND ServerSpans.parentSpanId = ClientSpans.spanId
|
||||
WHERE (ClientSpans.serviceName IS NULL OR ServerSpans.serviceName != ClientSpans.serviceName)
|
||||
GROUP BY serverServiceName, serverStatusCode, clientServiceName
|
||||
ORDER BY serverServiceName, serverStatusCode, clientServiceName
|
||||
`;
|
||||
}
|
||||
|
||||
type IncomingRequestStats = {
|
||||
totalRequests: number;
|
||||
requestCountByStatus: Map<string, number>;
|
||||
errorPercentage: number;
|
||||
};
|
||||
|
||||
export type ServiceAggregation = {
|
||||
serviceName: string;
|
||||
incomingRequests: IncomingRequestStats;
|
||||
incomingRequestsByClient: Map<string, IncomingRequestStats>;
|
||||
};
|
||||
|
||||
export function aggregateServiceMapData(data: SpanAggregationRow[]) {
|
||||
// Aggregate data by service
|
||||
const services = new Map<string, ServiceAggregation>();
|
||||
for (const row of data) {
|
||||
const {
|
||||
serverServiceName,
|
||||
serverStatusCode,
|
||||
clientServiceName,
|
||||
requestCount,
|
||||
} = row;
|
||||
|
||||
if (!services.has(serverServiceName)) {
|
||||
services.set(serverServiceName, {
|
||||
serviceName: serverServiceName,
|
||||
incomingRequests: {
|
||||
totalRequests: 0,
|
||||
requestCountByStatus: new Map(),
|
||||
errorPercentage: 0,
|
||||
},
|
||||
incomingRequestsByClient: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
const service = services.get(serverServiceName)!;
|
||||
|
||||
// Add to total incoming request count
|
||||
service.incomingRequests.totalRequests += requestCount;
|
||||
|
||||
// Add to request count per status
|
||||
const currentStatusCount =
|
||||
service.incomingRequests.requestCountByStatus.get(serverStatusCode) || 0;
|
||||
service.incomingRequests.requestCountByStatus.set(
|
||||
serverStatusCode,
|
||||
currentStatusCount + requestCount,
|
||||
);
|
||||
|
||||
// Add to request count per client per status
|
||||
if (clientServiceName) {
|
||||
if (!service.incomingRequestsByClient.has(clientServiceName)) {
|
||||
service.incomingRequestsByClient.set(clientServiceName, {
|
||||
totalRequests: 0,
|
||||
requestCountByStatus: new Map(),
|
||||
errorPercentage: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const perClientStats =
|
||||
service.incomingRequestsByClient.get(clientServiceName)!;
|
||||
perClientStats.totalRequests += requestCount;
|
||||
|
||||
const currentClientStatusCount =
|
||||
perClientStats.requestCountByStatus.get(serverStatusCode) || 0;
|
||||
perClientStats.requestCountByStatus.set(
|
||||
serverStatusCode,
|
||||
currentClientStatusCount + requestCount,
|
||||
);
|
||||
|
||||
if (!services.has(clientServiceName)) {
|
||||
services.set(clientServiceName, {
|
||||
serviceName: clientServiceName,
|
||||
incomingRequests: {
|
||||
totalRequests: 0,
|
||||
requestCountByStatus: new Map(),
|
||||
errorPercentage: 0,
|
||||
},
|
||||
incomingRequestsByClient: new Map(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate error percentages for all services and their client stats
|
||||
for (const service of services.values()) {
|
||||
// Calculate error percentage for total incoming requests
|
||||
const errorCount =
|
||||
service.incomingRequests.requestCountByStatus.get('Error') || 0;
|
||||
service.incomingRequests.errorPercentage =
|
||||
service.incomingRequests.totalRequests > 0
|
||||
? (errorCount / service.incomingRequests.totalRequests) * 100
|
||||
: 0;
|
||||
|
||||
// Calculate error percentage for each client
|
||||
for (const clientStats of service.incomingRequestsByClient.values()) {
|
||||
const clientErrorCount =
|
||||
clientStats.requestCountByStatus.get('Error') || 0;
|
||||
clientStats.errorPercentage =
|
||||
clientStats.totalRequests > 0
|
||||
? (clientErrorCount / clientStats.totalRequests) * 100
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
export default function useServiceMap({
|
||||
source,
|
||||
dateRange,
|
||||
traceId,
|
||||
samplingFactor,
|
||||
}: {
|
||||
source: TSource;
|
||||
dateRange: [Date, Date];
|
||||
traceId?: string;
|
||||
samplingFactor: number;
|
||||
}) {
|
||||
const client = useClickhouseClient();
|
||||
const metadata = useMetadataWithSettings();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['serviceMapData', traceId, source, dateRange, samplingFactor],
|
||||
queryFn: async ({ signal }) => {
|
||||
const query = await getServiceMapQuery({
|
||||
source,
|
||||
dateRange,
|
||||
traceId,
|
||||
metadata,
|
||||
samplingFactor,
|
||||
});
|
||||
|
||||
const data = await client
|
||||
.query({
|
||||
query: query.sql,
|
||||
query_params: query.params,
|
||||
connectionId: source.connection,
|
||||
format: 'JSON',
|
||||
abort_signal: signal,
|
||||
clickhouse_settings: {
|
||||
max_execution_time: 60,
|
||||
join_algorithm: 'auto',
|
||||
},
|
||||
})
|
||||
.then(res => res.json<Record<string, string>>())
|
||||
.then(data =>
|
||||
data.data.map((row: Record<string, string>) => ({
|
||||
serverServiceName: row.serverServiceName,
|
||||
serverStatusCode: row.serverStatusCode,
|
||||
clientServiceName: row.clientServiceName,
|
||||
requestCount: Number.parseInt(row.requestCount),
|
||||
})),
|
||||
);
|
||||
|
||||
return aggregateServiceMapData(data);
|
||||
},
|
||||
// Prevent refetching and updating the map layout
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
187
yarn.lock
187
yarn.lock
|
|
@ -3402,6 +3402,22 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dagrejs/dagre@npm:^1.1.5":
|
||||
version: 1.1.5
|
||||
resolution: "@dagrejs/dagre@npm:1.1.5"
|
||||
dependencies:
|
||||
"@dagrejs/graphlib": "npm:2.2.4"
|
||||
checksum: 10c0/738e2d86ee093b99ec96098f1a4d0b6290b6ddaef51db912885df0ab5d09291edb82ef875575a4b321bc13bab78b1cb00347b246aad4099ce4af3202a72a8586
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@dagrejs/graphlib@npm:2.2.4":
|
||||
version: 2.2.4
|
||||
resolution: "@dagrejs/graphlib@npm:2.2.4"
|
||||
checksum: 10c0/14597ea9294c46b2571aee78bcaad3a24e3e5e0ebcdf198b6eae5b3805f99af727ac54a477dd9152e8b0a576efea0528fb7d4919c74801e9f669c90e5e6f5bd9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@discoveryjs/json-ext@npm:^0.5.3":
|
||||
version: 0.5.7
|
||||
resolution: "@discoveryjs/json-ext@npm:0.5.7"
|
||||
|
|
@ -4384,6 +4400,7 @@ __metadata:
|
|||
"@chromatic-com/storybook": "npm:^1.5.0"
|
||||
"@codemirror/lang-json": "npm:^6.0.1"
|
||||
"@codemirror/lang-sql": "npm:^6.7.0"
|
||||
"@dagrejs/dagre": "npm:^1.1.5"
|
||||
"@hookform/devtools": "npm:^4.3.1"
|
||||
"@hookform/resolvers": "npm:^3.9.0"
|
||||
"@hyperdx/browser": "npm:^0.21.1"
|
||||
|
|
@ -4435,6 +4452,7 @@ __metadata:
|
|||
"@uiw/codemirror-theme-atomone": "npm:^4.23.3"
|
||||
"@uiw/codemirror-themes": "npm:^4.23.3"
|
||||
"@uiw/react-codemirror": "npm:^4.23.3"
|
||||
"@xyflow/react": "npm:^12.9.0"
|
||||
bootstrap: "npm:^5.1.3"
|
||||
chrono-node: "npm:^2.7.8"
|
||||
classnames: "npm:^2.3.1"
|
||||
|
|
@ -9631,6 +9649,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-drag@npm:^3.0.7":
|
||||
version: 3.0.7
|
||||
resolution: "@types/d3-drag@npm:3.0.7"
|
||||
dependencies:
|
||||
"@types/d3-selection": "npm:*"
|
||||
checksum: 10c0/65e29fa32a87c72d26c44b5e2df3bf15af21cd128386bcc05bcacca255927c0397d0cd7e6062aed5f0abd623490544a9d061c195f5ed9f018fe0b698d99c079d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-ease@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@types/d3-ease@npm:3.0.0"
|
||||
|
|
@ -9638,6 +9665,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.4":
|
||||
version: 3.0.4
|
||||
resolution: "@types/d3-interpolate@npm:3.0.4"
|
||||
dependencies:
|
||||
"@types/d3-color": "npm:*"
|
||||
checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-interpolate@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "@types/d3-interpolate@npm:3.0.1"
|
||||
|
|
@ -9663,6 +9699,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10":
|
||||
version: 3.0.11
|
||||
resolution: "@types/d3-selection@npm:3.0.11"
|
||||
checksum: 10c0/0c512956c7503ff5def4bb32e0c568cc757b9a2cc400a104fc0f4cfe5e56d83ebde2a97821b6f2cb26a7148079d3b86a2f28e11d68324ed311cf35c2ed980d1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-shape@npm:^3.1.0":
|
||||
version: 3.1.1
|
||||
resolution: "@types/d3-shape@npm:3.1.1"
|
||||
|
|
@ -9686,6 +9729,25 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-transition@npm:^3.0.8":
|
||||
version: 3.0.9
|
||||
resolution: "@types/d3-transition@npm:3.0.9"
|
||||
dependencies:
|
||||
"@types/d3-selection": "npm:*"
|
||||
checksum: 10c0/4f68b9df7ac745b3491216c54203cbbfa0f117ae4c60e2609cdef2db963582152035407fdff995b10ee383bae2f05b7743493f48e1b8e46df54faa836a8fb7b5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-zoom@npm:^3.0.8":
|
||||
version: 3.0.8
|
||||
resolution: "@types/d3-zoom@npm:3.0.8"
|
||||
dependencies:
|
||||
"@types/d3-interpolate": "npm:*"
|
||||
"@types/d3-selection": "npm:*"
|
||||
checksum: 10c0/1dbdbcafddcae12efb5beb6948546963f29599e18bc7f2a91fb69cc617c2299a65354f2d47e282dfb86fec0968406cd4fb7f76ba2d2fb67baa8e8d146eb4a547
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/debug@npm:^4.0.0":
|
||||
version: 4.1.7
|
||||
resolution: "@types/debug@npm:4.1.7"
|
||||
|
|
@ -11210,6 +11272,37 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@xyflow/react@npm:^12.9.0":
|
||||
version: 12.9.0
|
||||
resolution: "@xyflow/react@npm:12.9.0"
|
||||
dependencies:
|
||||
"@xyflow/system": "npm:0.0.71"
|
||||
classcat: "npm:^5.0.3"
|
||||
zustand: "npm:^4.4.0"
|
||||
peerDependencies:
|
||||
react: ">=17"
|
||||
react-dom: ">=17"
|
||||
checksum: 10c0/bf349a119d27aa22c3ed34afe2315a325c5ed35aa328c0c0c4eee7751e07828a6c0f32839c1240e839c6b6221b0c67b47030e4c62ef59ed4f06f8b1bcf4c5203
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@xyflow/system@npm:0.0.71":
|
||||
version: 0.0.71
|
||||
resolution: "@xyflow/system@npm:0.0.71"
|
||||
dependencies:
|
||||
"@types/d3-drag": "npm:^3.0.7"
|
||||
"@types/d3-interpolate": "npm:^3.0.4"
|
||||
"@types/d3-selection": "npm:^3.0.10"
|
||||
"@types/d3-transition": "npm:^3.0.8"
|
||||
"@types/d3-zoom": "npm:^3.0.8"
|
||||
d3-drag: "npm:^3.0.0"
|
||||
d3-interpolate: "npm:^3.0.1"
|
||||
d3-selection: "npm:^3.0.0"
|
||||
d3-zoom: "npm:^3.0.0"
|
||||
checksum: 10c0/c03db95ac41a1edd651a7941144640fc90f3a21e4eede1813e25525534229506e73625c8092308a5fb690147265336437f4dd6bbe781224ab2c12df6b98eb23d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@yarnpkg/esbuild-plugin-pnp@npm:^3.0.0-rc.10":
|
||||
version: 3.0.0-rc.15
|
||||
resolution: "@yarnpkg/esbuild-plugin-pnp@npm:3.0.0-rc.15"
|
||||
|
|
@ -13100,6 +13193,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"classcat@npm:^5.0.3":
|
||||
version: 5.0.5
|
||||
resolution: "classcat@npm:5.0.5"
|
||||
checksum: 10c0/ff8d273055ef9b518529cfe80fd0486f7057a9917373807ff802d75ceb46e8f8e148f41fa094ee7625c8f34642cfaa98395ff182d9519898da7cbf383d4a210d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"classnames@npm:^2.3.1":
|
||||
version: 2.3.2
|
||||
resolution: "classnames@npm:2.3.2"
|
||||
|
|
@ -13992,7 +14092,24 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-ease@npm:^3.0.1":
|
||||
"d3-dispatch@npm:1 - 3":
|
||||
version: 3.0.1
|
||||
resolution: "d3-dispatch@npm:3.0.1"
|
||||
checksum: 10c0/6eca77008ce2dc33380e45d4410c67d150941df7ab45b91d116dbe6d0a3092c0f6ac184dd4602c796dc9e790222bad3ff7142025f5fd22694efe088d1d941753
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-drag@npm:2 - 3, d3-drag@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "d3-drag@npm:3.0.0"
|
||||
dependencies:
|
||||
d3-dispatch: "npm:1 - 3"
|
||||
d3-selection: "npm:3"
|
||||
checksum: 10c0/d2556e8dc720741a443b595a30af403dd60642dfd938d44d6e9bfc4c71a962142f9a028c56b61f8b4790b65a34acad177d1263d66f103c3c527767b0926ef5aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-ease@npm:1 - 3, d3-ease@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-ease@npm:3.0.1"
|
||||
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
|
||||
|
|
@ -14006,7 +14123,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
|
||||
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-interpolate@npm:3.0.1"
|
||||
dependencies:
|
||||
|
|
@ -14035,6 +14152,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "d3-selection@npm:3.0.0"
|
||||
checksum: 10c0/e59096bbe8f0cb0daa1001d9bdd6dbc93a688019abc97d1d8b37f85cd3c286a6875b22adea0931b0c88410d025563e1643019161a883c516acf50c190a11b56b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-shape@npm:^3.1.0":
|
||||
version: 3.2.0
|
||||
resolution: "d3-shape@npm:3.2.0"
|
||||
|
|
@ -14062,13 +14186,41 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-timer@npm:^3.0.1":
|
||||
"d3-timer@npm:1 - 3, d3-timer@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "d3-timer@npm:3.0.1"
|
||||
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-transition@npm:2 - 3":
|
||||
version: 3.0.1
|
||||
resolution: "d3-transition@npm:3.0.1"
|
||||
dependencies:
|
||||
d3-color: "npm:1 - 3"
|
||||
d3-dispatch: "npm:1 - 3"
|
||||
d3-ease: "npm:1 - 3"
|
||||
d3-interpolate: "npm:1 - 3"
|
||||
d3-timer: "npm:1 - 3"
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
checksum: 10c0/4e74535dda7024aa43e141635b7522bb70cf9d3dfefed975eb643b36b864762eca67f88fafc2ca798174f83ca7c8a65e892624f824b3f65b8145c6a1a88dbbad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-zoom@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "d3-zoom@npm:3.0.0"
|
||||
dependencies:
|
||||
d3-dispatch: "npm:1 - 3"
|
||||
d3-drag: "npm:2 - 3"
|
||||
d3-interpolate: "npm:1 - 3"
|
||||
d3-selection: "npm:2 - 3"
|
||||
d3-transition: "npm:2 - 3"
|
||||
checksum: 10c0/ee2036479049e70d8c783d594c444fe00e398246048e3f11a59755cd0e21de62ece3126181b0d7a31bf37bcf32fd726f83ae7dea4495ff86ec7736ce5ad36fd3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"damerau-levenshtein@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "damerau-levenshtein@npm:1.0.8"
|
||||
|
|
@ -28934,6 +29086,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"use-sync-external-store@npm:^1.2.2":
|
||||
version: 1.6.0
|
||||
resolution: "use-sync-external-store@npm:1.6.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
|
||||
version: 1.0.2
|
||||
resolution: "util-deprecate@npm:1.0.2"
|
||||
|
|
@ -29834,6 +29995,26 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zustand@npm:^4.4.0":
|
||||
version: 4.5.7
|
||||
resolution: "zustand@npm:4.5.7"
|
||||
dependencies:
|
||||
use-sync-external-store: "npm:^1.2.2"
|
||||
peerDependencies:
|
||||
"@types/react": ">=16.8"
|
||||
immer: ">=9.0.6"
|
||||
react: ">=16.8"
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
checksum: 10c0/55559e37a82f0c06cadc61cb08f08314c0fe05d6a93815e41e3376130c13db22a5017cbb0cd1f018c82f2dad0051afe3592561d40f980bd4082e32005e8a950c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zwitch@npm:^2.0.0":
|
||||
version: 2.0.4
|
||||
resolution: "zwitch@npm:2.0.4"
|
||||
|
|
|
|||
Loading…
Reference in a new issue