Skip to content

Commit 91e443f

Browse files
authored
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.
1 parent 24bf2b4 commit 91e443f

File tree

18 files changed

+2080
-4
lines changed

18 files changed

+2080
-4
lines changed

.changeset/itchy-grapes-fail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": minor
3+
---
4+
5+
feat: Add service maps (beta)

packages/app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"dependencies": {
2828
"@codemirror/lang-json": "^6.0.1",
2929
"@codemirror/lang-sql": "^6.7.0",
30+
"@dagrejs/dagre": "^1.1.5",
3031
"@hookform/resolvers": "^3.9.0",
3132
"@hyperdx/browser": "^0.21.1",
3233
"@hyperdx/common-utils": "^0.7.2",
@@ -48,6 +49,7 @@
4849
"@uiw/codemirror-theme-atomone": "^4.23.3",
4950
"@uiw/codemirror-themes": "^4.23.3",
5051
"@uiw/react-codemirror": "^4.23.3",
52+
"@xyflow/react": "^12.9.0",
5153
"bootstrap": "^5.1.3",
5254
"chrono-node": "^2.7.8",
5355
"classnames": "^2.3.1",

packages/app/pages/_app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import '@mantine/dropzone/styles.css';
3030
import '@styles/globals.css';
3131
import '@styles/app.scss';
3232
import 'uplot/dist/uPlot.min.css';
33+
import '@xyflow/react/dist/style.css';
3334

3435
// Polyfill crypto.randomUUID for non-HTTPS environments
3536
if (typeof crypto !== 'undefined' && !crypto.randomUUID) {

packages/app/pages/service-map.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import DBServiceMapPage from '@/DBServiceMapPage';
2+
export default DBServiceMapPage;

packages/app/src/AppNav.components.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import cx from 'classnames';
44
import {
55
ActionIcon,
66
Avatar,
7+
Badge,
78
Button,
89
Group,
910
Menu,
@@ -298,13 +299,15 @@ export const AppNavLink = ({
298299
href,
299300
isExpanded,
300301
onToggle,
302+
isBeta,
301303
}: {
302304
className?: string;
303305
label: React.ReactNode;
304306
iconName: string;
305307
href: string;
306308
isExpanded?: boolean;
307309
onToggle?: () => void;
310+
isBeta?: boolean;
308311
}) => {
309312
const { pathname, isCollapsed } = React.useContext(AppNavContext);
310313

@@ -324,7 +327,23 @@ export const AppNavLink = ({
324327
>
325328
<span>
326329
<i className={`bi ${iconName} pe-2 text-slate-300`} />{' '}
327-
{!isCollapsed && <span>{label}</span>}
330+
{!isCollapsed && (
331+
<span>
332+
{label}
333+
{isBeta && (
334+
<Badge
335+
size="xs"
336+
ms="xs"
337+
color="gray.4"
338+
autoContrast
339+
radius="sm"
340+
className="align-text-bottom"
341+
>
342+
Beta
343+
</Badge>
344+
)}
345+
</span>
346+
)}
328347
</span>
329348
</Link>
330349
{!isCollapsed && onToggle && (

packages/app/src/AppNav.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,13 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
730730
iconName="bi-laptop"
731731
/>
732732

733+
<AppNavLink
734+
label="Service Map"
735+
href="/service-map"
736+
iconName="bi-diagram-2-fill"
737+
isBeta
738+
/>
739+
733740
<AppNavLink
734741
label="Dashboards"
735742
href="/dashboards"
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { useState } from 'react';
2+
import dynamic from 'next/dynamic';
3+
import { parseAsInteger, useQueryState } from 'nuqs';
4+
import { useForm } from 'react-hook-form';
5+
import { SourceKind } from '@hyperdx/common-utils/dist/types';
6+
import { Box, Group, Slider, Text } from '@mantine/core';
7+
8+
import { withAppNav } from '@/layout';
9+
10+
import ServiceMap from './components/ServiceMap/ServiceMap';
11+
import SourceSchemaPreview from './components/SourceSchemaPreview';
12+
import { SourceSelectControlled } from './components/SourceSelect';
13+
import { TimePicker } from './components/TimePicker';
14+
import { useSources } from './source';
15+
import { parseTimeQuery, useNewTimeQuery } from './timeQuery';
16+
17+
// The % of requests sampled is 1 / sampling factor
18+
export const SAMPLING_FACTORS = [
19+
{
20+
value: 100,
21+
label: '1%',
22+
},
23+
{
24+
value: 20,
25+
label: '5%',
26+
},
27+
{
28+
value: 10,
29+
label: '10%',
30+
},
31+
{
32+
value: 2,
33+
label: '50%',
34+
},
35+
{
36+
value: 1,
37+
label: '100%',
38+
},
39+
];
40+
41+
const DEFAULT_INTERVAL = 'Past 1h';
42+
const defaultTimeRange = parseTimeQuery(DEFAULT_INTERVAL, false) as [
43+
Date,
44+
Date,
45+
];
46+
47+
function DBServiceMapPage() {
48+
const { data: sources } = useSources();
49+
const [sourceId, setSourceId] = useQueryState('source');
50+
51+
const [displayedTimeInputValue, setDisplayedTimeInputValue] =
52+
useState(DEFAULT_INTERVAL);
53+
54+
const { searchedTimeRange, onSearch } = useNewTimeQuery({
55+
initialDisplayValue: DEFAULT_INTERVAL,
56+
initialTimeRange: defaultTimeRange,
57+
setDisplayedTimeInputValue,
58+
});
59+
60+
const defaultSource = sources?.find(
61+
source => source.kind === SourceKind.Trace,
62+
);
63+
const source =
64+
sourceId && sources
65+
? (sources.find(
66+
source => source.id === sourceId && source.kind === SourceKind.Trace,
67+
) ?? defaultSource)
68+
: defaultSource;
69+
70+
const { control, watch } = useForm({
71+
values: {
72+
source: source?.id,
73+
},
74+
});
75+
76+
watch((data, { name, type }) => {
77+
if (name === 'source' && type === 'change') {
78+
setSourceId(data.source ?? null);
79+
}
80+
});
81+
82+
const [samplingFactor, setSamplingFactor] = useQueryState(
83+
'samplingFactor',
84+
parseAsInteger.withDefault(10),
85+
);
86+
const { label: samplingLabel = '' } =
87+
SAMPLING_FACTORS.find(factor => factor.value === samplingFactor) ?? {};
88+
89+
return source ? (
90+
<Box
91+
data-testid="service-map-page"
92+
p="sm"
93+
className="bg-hdx-dark"
94+
style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}
95+
>
96+
<Group mb="md" justify="space-between">
97+
<Group>
98+
<Text c="gray.4" size="xl">
99+
Service Map
100+
</Text>
101+
<SourceSelectControlled
102+
control={control}
103+
name="source"
104+
size="xs"
105+
allowedSourceKinds={[SourceKind.Trace]}
106+
sourceSchemaPreview={
107+
<SourceSchemaPreview source={source} variant="text" />
108+
}
109+
/>
110+
</Group>
111+
<Group justify="flex-end">
112+
<Text c="gray.4" bg="inherit" size="sm">
113+
Sampling {samplingLabel}
114+
</Text>
115+
<div style={{ minWidth: '200px' }}>
116+
<Slider
117+
label={null}
118+
color="green"
119+
min={0}
120+
max={SAMPLING_FACTORS.length - 1}
121+
value={SAMPLING_FACTORS.findIndex(
122+
factor => factor.value === samplingFactor,
123+
)}
124+
onChange={v => setSamplingFactor(SAMPLING_FACTORS[v].value)}
125+
showLabelOnHover={false}
126+
/>
127+
</div>
128+
<TimePicker
129+
inputValue={displayedTimeInputValue}
130+
setInputValue={setDisplayedTimeInputValue}
131+
onSearch={onSearch}
132+
/>
133+
</Group>
134+
</Group>
135+
<ServiceMap
136+
traceTableSource={source}
137+
dateRange={searchedTimeRange}
138+
samplingFactor={samplingFactor}
139+
/>
140+
</Box>
141+
) : null;
142+
}
143+
144+
const DBServiceMapPageDynamic = dynamic(async () => DBServiceMapPage, {
145+
ssr: false,
146+
});
147+
148+
// @ts-ignore
149+
DBServiceMapPageDynamic.getLayout = withAppNav;
150+
151+
export default DBServiceMapPageDynamic;

packages/app/src/components/DBTracePanel.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
44
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
55
import { SourceKind } from '@hyperdx/common-utils/dist/types';
66
import {
7+
Badge,
78
Button,
89
Center,
910
Divider,
@@ -18,6 +19,7 @@ import { DBTraceWaterfallChartContainer } from '@/components/DBTraceWaterfallCha
1819
import { useSource, useUpdateSource } from '@/source';
1920
import TabBar from '@/TabBar';
2021

22+
import ServiceMap from './ServiceMap/ServiceMap';
2123
import { RowDataPanel } from './DBRowDataPanel';
2224
import { RowOverviewPanel } from './DBRowOverviewPanel';
2325
import { SourceSelectControlled } from './SourceSelect';
@@ -207,6 +209,28 @@ export default function DBTracePanel({
207209
)}
208210
{traceSourceData != null && eventRowWhere != null && (
209211
<>
212+
<Divider my="md" />
213+
<Group>
214+
<Text size="sm" c="dark.2" my="sm">
215+
Service Map
216+
</Text>
217+
<Badge
218+
size="xs"
219+
color="gray.4"
220+
autoContrast
221+
radius="sm"
222+
className="align-text-bottom"
223+
>
224+
Beta
225+
</Badge>
226+
</Group>
227+
<div style={{ height: '300px', width: '100%', display: 'flex' }}>
228+
<ServiceMap
229+
traceId={traceId}
230+
traceTableSource={traceSourceData}
231+
dateRange={dateRange}
232+
/>
233+
</div>
210234
<Divider my="md" />
211235
<Text size="sm" c="dark.2" my="sm">
212236
Event Details
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
.container {
2+
display: flex;
3+
flex: 1;
4+
min-height: 100px;
5+
width: 100%;
6+
}
7+
8+
.toolbar {
9+
padding: 4px 8px;
10+
border-radius: 4px;
11+
background-color: #f0f0f0;
12+
border: 1px solid #ccc;
13+
color: #111;
14+
15+
.linkButton {
16+
font-size: small;
17+
}
18+
}
19+
20+
.serviceNode {
21+
display: flex;
22+
flex-direction: column;
23+
align-items: center;
24+
cursor: pointer;
25+
26+
.body {
27+
display: flex;
28+
flex-direction: row;
29+
align-items: center;
30+
justify-content: center;
31+
32+
.circle {
33+
width: 40px;
34+
height: 40px;
35+
background-color: #f0f0f0;
36+
border: 1px solid #ccc;
37+
border-radius: 50%;
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)