Skip to content
7 changes: 7 additions & 0 deletions .changeset/service-map-group-by.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@hyperdx/app': minor
---

feat: split the service map by up to 3 extra group-by SQL expressions (for
example `ResourceAttributes['k8s.pod.name']`), so a node is a service plus
those values and a slow or failing pod is no longer averaged into its service.
51 changes: 40 additions & 11 deletions packages/app/src/DBServiceMapPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ import { PageLayout } from '@/components/PageLayout';
import SearchWhereInput, {
getStoredLanguage,
} from '@/components/SearchInput/SearchWhereInput';
import { parseServiceMapGroupBy } from '@/components/ServiceMap/dimensions';
import ServiceMapGroupByInput, {
ServiceMapSearchFormValues,
} from '@/components/ServiceMap/ServiceMapGroupByInput';
import { IS_LOCAL_MODE } from '@/config';
import { useGetKeyValues } from '@/hooks/useMetadata';
import { useResolvedSourceParam } from '@/hooks/useResolvedSourceParam';
Expand Down Expand Up @@ -86,6 +90,8 @@ const searchQueryStateMap = {
where: parseAsStringEncoded,
whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']),
services: parseAsArrayOf(parseAsString),
// Comma-separated SQL expressions the nodes are split by, after ServiceName.
groupBy: parseAsStringEncoded,
};

function DBServiceMapPage() {
Expand Down Expand Up @@ -121,14 +127,22 @@ function DBServiceMapPage() {
// useResolvedSourceParam will show a notification to the user.
const source = paramSource ?? defaultSource;

const { control, handleSubmit, setValue } = useForm({
values: {
source: source?.id,
where: searchedConfig.where ?? '',
whereLanguage:
searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene',
},
});
const groupBy = useMemo(
() => parseServiceMapGroupBy(searchedConfig.groupBy),
[searchedConfig.groupBy],
);
const { control, handleSubmit, setValue } =
useForm<ServiceMapSearchFormValues>({
values: {
source: source?.id,
where: searchedConfig.where ?? '',
whereLanguage:
searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene',
// Seeded from the parsed list so the editor shows exactly what the map
// is grouped by, even for a hand-edited URL with too many expressions.
groupBy: groupBy.join(', '),
},
});

const watchedSource = useWatch({ control, name: 'source' });
const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] =
Expand Down Expand Up @@ -207,10 +221,17 @@ function DBServiceMapPage() {
[setSearchedConfig],
);

// The time range is applied together with the form values, so an invalid
// group-by leaves the URL untouched instead of moving the time window.
const onSubmit = useCallback(() => {
onSearch(displayedTimeInputValue);
handleSubmit(({ where, whereLanguage }) => {
setSearchedConfig(prev => ({ ...prev, where, whereLanguage }));
handleSubmit(({ where, whereLanguage, groupBy }) => {
onSearch(displayedTimeInputValue);
setSearchedConfig(prev => ({
...prev,
where,
whereLanguage,
groupBy: groupBy || null,
}));
})();
}, [handleSubmit, setSearchedConfig, displayedTimeInputValue, onSearch]);

Expand Down Expand Up @@ -410,6 +431,13 @@ function DBServiceMapPage() {
sqlPlaceholder="SQL WHERE to filter spans (ex. Duration > 1000000)"
minWidth="min(500px, 100%)"
/>
<ServiceMapGroupByInput
control={control}
tableConnection={sourceTableConnection}
sourceId={source.id}
dateRange={searchedTimeRange}
onSubmit={onSubmit}
/>
</Flex>
<ServiceMap
traceTableSource={source}
Expand All @@ -420,6 +448,7 @@ function DBServiceMapPage() {
serviceNames={
selectedServices.length > 0 ? selectedServices : undefined
}
groupBy={groupBy}
onFocusService={onFocusService}
/>
</>
Expand Down
67 changes: 60 additions & 7 deletions packages/app/src/components/ServiceMap/ServiceMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { TTraceSource } from '@hyperdx/common-utils/dist/types';
import {
Box,
Button,
Center,
Code,
Loader,
Expand All @@ -28,6 +29,7 @@ import {
} from '@xyflow/react';

import { SQLPreview } from '@/components/ChartSQLPreview';
import EmptyState from '@/components/EmptyState';
import useServiceMap, { ServiceAggregation } from '@/hooks/useServiceMap';
import { useResolvedColorScheme } from '@/useUserPreferences';

Expand All @@ -38,8 +40,10 @@ import {
ServiceMapMetricMax,
} from './ServiceMapMetricContext';
import ServiceMapNode, { ServiceMapNodeData } from './ServiceMapNode';
import { useServiceMapSizeGate } from './useServiceMapSizeGate';
import {
getServiceMetricValue,
MAX_SERVICE_MAP_NODES,
SERVICE_MAP_METRIC_LABEL,
SERVICE_MAP_METRICS,
ServiceMapMetric,
Expand Down Expand Up @@ -96,6 +100,8 @@ interface ServiceMapPresentationProps {
dateRange: [Date, Date];
source: TTraceSource;
isSingleTrace?: boolean;
// Extra group-by SQL expressions the nodes are split by (beyond ServiceName).
groupBy: string[];
// The single service the map is currently scoped to (via a node's focus
// action), or undefined when showing all/multiple services.
focusedService?: string;
Expand All @@ -109,6 +115,7 @@ function ServiceMapPresentation({
dateRange,
source,
isSingleTrace,
groupBy,
focusedService,
onFocusService,
}: ServiceMapPresentationProps) {
Expand All @@ -117,6 +124,7 @@ function ServiceMapPresentation({
const { fitView } = useReactFlow();
const colorScheme = useResolvedColorScheme();
const [metric, setMetric] = useState<ServiceMapMetric>('errorRate');
const { tooLarge, showAnyway } = useServiceMapSizeGate(services);

// Fit the data to the viewport whenever input service information changes
useEffect(() => {
Expand Down Expand Up @@ -166,15 +174,24 @@ function ServiceMapPresentation({
}, [metric, hasLatencyData]);

useEffect(() => {
// Skip the layout entirely for an oversized map; dagre on thousands of
// nodes is what locks the tab, not the query.
if (tooLarge) {
setNodes([]);
setEdges([]);
return;
}

const nodes: Node<ServiceMapNodeData>[] =
Array.from(services?.values() ?? []).map((service, index) => ({
id: service.serviceName,
id: service.key,
data: {
...service,
dateRange,
source,
maxThroughput: metricMax.throughput,
isSingleTrace,
groupBy,
focusedService,
onFocusService,
},
Expand All @@ -186,16 +203,16 @@ function ServiceMapPresentation({
services?.values() ?? [],
)
.filter(service => service.incomingRequestsByClient.size > 0)
.flatMap(({ serviceName, incomingRequestsByClient: requestsByClient }) =>
Array.from(requestsByClient.entries()).map(
.flatMap(({ key, serviceName, dimensions, incomingRequestsByClient }) =>
Array.from(incomingRequestsByClient.entries()).map(
([
clientServiceName,
clientKey,
{ totalRequests, errorPercentage, p50, p95, p99, hasLatency },
]) => {
return {
id: `${serviceName}-${clientServiceName}`,
source: clientServiceName,
target: serviceName,
id: `${clientKey}->${key}`,
source: clientKey,
target: key,
animated: true,
type: 'request',
data: {
Expand All @@ -208,6 +225,8 @@ function ServiceMapPresentation({
source,
dateRange,
serviceName,
dimensions,
groupBy,
isSingleTrace,
},
};
Expand All @@ -221,10 +240,12 @@ function ServiceMapPresentation({
setEdges(edges);
}, [
services,
tooLarge,
dateRange,
source,
metricMax.throughput,
isSingleTrace,
groupBy,
focusedService,
onFocusService,
]);
Expand All @@ -248,6 +269,29 @@ function ServiceMapPresentation({
);
}

if (services && tooLarge) {
return (
<Center className="w-100 h-100">
<EmptyState
title={`${services.size.toLocaleString()} nodes in this map`}
description={`The service map renders up to ${MAX_SERVICE_MAP_NODES} nodes. Narrow the span filter or group by an attribute with fewer values.`}
maw={480}
data-testid="service-map-too-many-nodes"
>
<Button
variant="secondary"
size="sm"
mt="sm"
onClick={showAnyway}
data-testid="service-map-show-anyway"
>
Show anyway
</Button>
</EmptyState>
</Center>
);
}

if (error) {
return (
<Box>
Expand Down Expand Up @@ -336,11 +380,17 @@ interface ServiceMapProps {
where?: string;
whereLanguage?: 'sql' | 'lucene';
serviceNames?: string[];
// Extra group-by SQL expressions; ServiceName is always the first key.
groupBy?: string[];
// Called when a node is clicked, e.g. to drive the service filter to focus
// on that service and its immediate dependencies.
onFocusService?: (serviceName: string) => void;
}

// A stable default so the layout effect does not re-run on every render when
// the caller passes no group-by.
const NO_GROUP_BY: string[] = [];

export default function ServiceMap({
traceId,
traceTableSource,
Expand All @@ -350,6 +400,7 @@ export default function ServiceMap({
where,
whereLanguage,
serviceNames,
groupBy = NO_GROUP_BY,
onFocusService,
}: ServiceMapProps) {
const {
Expand All @@ -364,6 +415,7 @@ export default function ServiceMap({
where,
whereLanguage,
serviceNames,
groupBy,
});

useEffect(() => {
Expand All @@ -390,6 +442,7 @@ export default function ServiceMap({
dateRange={dateRange}
source={traceTableSource}
isSingleTrace={isSingleTrace}
groupBy={groupBy}
focusedService={focusedService}
onFocusService={onFocusService}
/>
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/components/ServiceMap/ServiceMapEdge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export type ServiceMapEdgeData = {
hasLatency: boolean;
dateRange: [Date, Date];
source: TTraceSource;
// The server (target) node's label parts.
serviceName: string;
dimensions: string[];
groupBy: string[];
isSingleTrace?: boolean;
};

Expand All @@ -42,6 +45,8 @@ export default function ServiceMapEdge(
hasLatency,
dateRange,
serviceName,
dimensions,
groupBy,
source,
isSingleTrace,
} = props.data;
Expand Down Expand Up @@ -70,6 +75,8 @@ export default function ServiceMapEdge(
source={source}
dateRange={dateRange}
serviceName={serviceName}
dimensions={dimensions}
groupBy={groupBy}
isSingleTrace={isSingleTrace}
/>
</EdgeToolbar>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Control, useFormState } from 'react-hook-form';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils';
import { Box, Text } from '@mantine/core';

import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { MAX_SERVICE_MAP_GROUP_BY } from '@/hooks/useServiceMap';

export type ServiceMapSearchFormValues = {
source?: string;
where: string;
whereLanguage: 'sql' | 'lucene';
// Comma-separated extra group-by SQL expressions.
groupBy: string;
};

function validateGroupBy(value: string): true | string {
return (
splitAndTrimWithBracket(value).length <= MAX_SERVICE_MAP_GROUP_BY ||
`Up to ${MAX_SERVICE_MAP_GROUP_BY} group by expressions`
);
}

/** The "Group by" SQL editor, with the expression-count validation inline. */
export default function ServiceMapGroupByInput({
control,
tableConnection,
sourceId,
dateRange,
onSubmit,
}: {
control: Control<ServiceMapSearchFormValues>;
tableConnection: TableConnection;
sourceId: string;
dateRange: [Date, Date];
onSubmit: () => void;
}) {
const { errors } = useFormState({ control, name: 'groupBy' });

return (
<Box
flex={1}
miw="min(320px, 100%)"
data-testid="service-map-group-by-input"
>
<SQLInlineEditorControlled
tableConnection={tableConnection}
sourceId={sourceId}
control={control}
name="groupBy"
rules={{ validate: validateGroupBy }}
label="Group by"
placeholder="ResourceAttributes['k8s.pod.name']"
tooltipText={`Split services by up to ${MAX_SERVICE_MAP_GROUP_BY} resource attributes, e.g. pod, node or version. Span attributes describe each call, so they split callers by the callee's values.`}
onSubmit={onSubmit}
disableKeywordAutocomplete
dateRange={dateRange}
size="xs"
/>
{errors.groupBy?.message ? (
<Text
variant="danger"
size="xs"
mt={2}
data-testid="service-map-group-by-error"
>
{errors.groupBy.message}
</Text>
) : null}
</Box>
);
}
Loading
Loading