diff --git a/.changeset/itchy-grapes-fail.md b/.changeset/itchy-grapes-fail.md new file mode 100644 index 00000000..75df0947 --- /dev/null +++ b/.changeset/itchy-grapes-fail.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": minor +--- + +feat: Add service maps (beta) diff --git a/packages/app/package.json b/packages/app/package.json index 6b0bec41..50451511 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 4a70ceea..ebaedfb3 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -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) { diff --git a/packages/app/pages/service-map.tsx b/packages/app/pages/service-map.tsx new file mode 100644 index 00000000..2267bc15 --- /dev/null +++ b/packages/app/pages/service-map.tsx @@ -0,0 +1,2 @@ +import DBServiceMapPage from '@/DBServiceMapPage'; +export default DBServiceMapPage; diff --git a/packages/app/src/AppNav.components.tsx b/packages/app/src/AppNav.components.tsx index 7520f71e..620b31d0 100644 --- a/packages/app/src/AppNav.components.tsx +++ b/packages/app/src/AppNav.components.tsx @@ -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 = ({ > {' '} - {!isCollapsed && {label}} + {!isCollapsed && ( + + {label} + {isBeta && ( + + Beta + + )} + + )} {!isCollapsed && onToggle && ( diff --git a/packages/app/src/AppNav.tsx b/packages/app/src/AppNav.tsx index e1c1f3fe..7dbc8977 100644 --- a/packages/app/src/AppNav.tsx +++ b/packages/app/src/AppNav.tsx @@ -730,6 +730,13 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { iconName="bi-laptop" /> + + 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 ? ( + + + + + Service Map + + + } + /> + + + + Sampling {samplingLabel} + +
+ factor.value === samplingFactor, + )} + onChange={v => setSamplingFactor(SAMPLING_FACTORS[v].value)} + showLabelOnHover={false} + /> +
+ +
+
+ +
+ ) : null; +} + +const DBServiceMapPageDynamic = dynamic(async () => DBServiceMapPage, { + ssr: false, +}); + +// @ts-ignore +DBServiceMapPageDynamic.getLayout = withAppNav; + +export default DBServiceMapPageDynamic; diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index 533a767e..d3cbad96 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -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 && ( <> + + + + Service Map + + + Beta + + +
+ +
Event Details diff --git a/packages/app/src/components/ServiceMap/ServiceMap.module.scss b/packages/app/src/components/ServiceMap/ServiceMap.module.scss new file mode 100644 index 00000000..c8b5ca03 --- /dev/null +++ b/packages/app/src/components/ServiceMap/ServiceMap.module.scss @@ -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%; + } + } +} diff --git a/packages/app/src/components/ServiceMap/ServiceMap.tsx b/packages/app/src/components/ServiceMap/ServiceMap.tsx new file mode 100644 index 00000000..3069ec04 --- /dev/null +++ b/packages/app/src/components/ServiceMap/ServiceMap.tsx @@ -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 | undefined; + isLoading: boolean; + error: Error | null; + dateRange: [Date, Date]; + source: TSource; +} + +function ServiceMapPresentation({ + services, + isLoading, + error, + dateRange, + source, +}: ServiceMapPresentationProps) { + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + + const onNodesChange = useCallback( + (changes: NodeChange[]) => + setNodes(nodesSnapshot => applyNodeChanges(changes, nodesSnapshot)), + [], + ); + + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => + 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[] = + 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[] = 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 ( +
+ +
+ ); + } + + if (error) { + return ( + + + Error message: + + + {error?.message} + + {error instanceof ClickHouseQueryError && ( + + + Original query: + + + + + + )} + + ); + } + + return ( +
+ + + +
+ ); +} + +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 ( + + ); +} diff --git a/packages/app/src/components/ServiceMap/ServiceMapEdge.tsx b/packages/app/src/components/ServiceMap/ServiceMapEdge.tsx new file mode 100644 index 00000000..806a6b50 --- /dev/null +++ b/packages/app/src/components/ServiceMap/ServiceMapEdge.tsx @@ -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>, +) { + const [edgePath, centerX, centerY] = getBezierPath(props); + + if (!props.data) { + return null; + } + + const { totalRequests, errorPercentage, dateRange, serviceName, source } = + props.data; + + return ( + <> + + + + + + ); +} diff --git a/packages/app/src/components/ServiceMap/ServiceMapNode.tsx b/packages/app/src/components/ServiceMap/ServiceMapNode.tsx new file mode 100644 index 00000000..0838a81d --- /dev/null +++ b/packages/app/src/components/ServiceMap/ServiceMapNode.tsx @@ -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>, +) { + const { data } = props; + const { + serviceName, + incomingRequests: { + totalRequests: totalIncomingRequestCount, + errorPercentage, + }, + source, + dateRange, + maxErrorPercentage, + } = data; + + const { backgroundColor, borderColor } = getNodeColors( + errorPercentage, + maxErrorPercentage, + props.selected, + ); + + return ( + <> + + + +
+
+
+ +
+
+
+ +
+
+ {serviceName} +
+ + ); +} diff --git a/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx b/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx new file mode 100644 index 00000000..7bb59cac --- /dev/null +++ b/packages/app/src/components/ServiceMap/ServiceMapTooltip.tsx @@ -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 ( +
+ + 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' : ''} + + {errorPercentage > 0 ? ( + <> + {', '} + + 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 + + + ) : null} +
+ ); +} diff --git a/packages/app/src/components/ServiceMap/__tests__/utils.test.ts b/packages/app/src/components/ServiceMap/__tests__/utils.test.ts new file mode 100644 index 00000000..3d7ee039 --- /dev/null +++ b/packages/app/src/components/ServiceMap/__tests__/utils.test.ts @@ -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); + }); + }); +}); diff --git a/packages/app/src/components/ServiceMap/utils.ts b/packages/app/src/components/ServiceMap/utils.ts new file mode 100644 index 00000000..a14eb3dd --- /dev/null +++ b/packages/app/src/components/ServiceMap/utils.ts @@ -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, + }; +} diff --git a/packages/app/src/hooks/__tests__/useServiceMap.test.ts b/packages/app/src/hooks/__tests__/useServiceMap.test.ts new file mode 100644 index 00000000..c34534ac --- /dev/null +++ b/packages/app/src/hooks/__tests__/useServiceMap.test.ts @@ -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); + }); + }); +}); diff --git a/packages/app/src/hooks/useServiceMap.tsx b/packages/app/src/hooks/useServiceMap.tsx new file mode 100644 index 00000000..eb26554e --- /dev/null +++ b/packages/app/src/hooks/useServiceMap.tsx @@ -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; + errorPercentage: number; +}; + +export type ServiceAggregation = { + serviceName: string; + incomingRequests: IncomingRequestStats; + incomingRequestsByClient: Map; +}; + +export function aggregateServiceMapData(data: SpanAggregationRow[]) { + // Aggregate data by service + const services = new Map(); + 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>()) + .then(data => + data.data.map((row: Record) => ({ + 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, + }); +} diff --git a/yarn.lock b/yarn.lock index bda6933e..aa74ee95 100644 --- a/yarn.lock +++ b/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"