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:
Drew Davis 2025-11-04 15:20:26 -05:00 committed by GitHub
parent 24bf2b419d
commit 91e443f431
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2080 additions and 4 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": minor
---
feat: Add service maps (beta)

View file

@ -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",

View file

@ -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) {

View file

@ -0,0 +1,2 @@
import DBServiceMapPage from '@/DBServiceMapPage';
export default DBServiceMapPage;

View file

@ -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 && (

View file

@ -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"

View 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;

View file

@ -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

View file

@ -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%;
}
}
}

View 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}
/>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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);
});
});
});

View 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,
};
}

View 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);
});
});
});

View 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
View file

@ -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"