From 41c5ae1ba8e4fea890ce69c5b2af219377b976e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 21:16:58 +0000 Subject: [PATCH 1/4] feat: add Lucene/SQL search filter to Service Map page Support filtering on Service Maps via a Lucene/SQL search bar on span data. Changes: - Add SearchWhereInput to DBServiceMapPage with Lucene/SQL toggle - Thread where/whereLanguage through ServiceMap component to useServiceMap hook - Pass user filters into both server and client span CTEs via renderChartConfig - Include implicitColumnExpression from source for Lucene parsing support - Store where/whereLanguage in URL params via nuqs for shareable links - Include filter params in TanStack Query key for automatic refetching Co-authored-by: Mike Shi --- packages/app/src/DBServiceMapPage.tsx | 58 +++++++++++++++++-- .../src/components/ServiceMap/ServiceMap.tsx | 6 ++ packages/app/src/hooks/useServiceMap.tsx | 27 ++++++++- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/app/src/DBServiceMapPage.tsx b/packages/app/src/DBServiceMapPage.tsx index b9d209410f..6ebcaed7ba 100644 --- a/packages/app/src/DBServiceMapPage.tsx +++ b/packages/app/src/DBServiceMapPage.tsx @@ -1,15 +1,25 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import dynamic from 'next/dynamic'; import Head from 'next/head'; -import { parseAsInteger, useQueryState } from 'nuqs'; +import { + parseAsInteger, + parseAsStringEnum, + useQueryState, + useQueryStates, +} from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; +import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types'; import { Box, Button, Group, Modal, Slider, Text } from '@mantine/core'; import { IconConnection } from '@tabler/icons-react'; import EmptyState from '@/components/EmptyState'; +import SearchWhereInput, { + getStoredLanguage, +} from '@/components/SearchInput/SearchWhereInput'; import { IS_LOCAL_MODE } from '@/config'; import { withAppNav } from '@/layout'; +import { parseAsStringEncoded } from '@/utils/queryParsers'; import OnboardingModal from './components/OnboardingModal'; import ServiceMap from './components/ServiceMap/ServiceMap'; @@ -51,6 +61,11 @@ const defaultTimeRange = parseTimeQuery(DEFAULT_INTERVAL, false) as [ Date, ]; +const searchQueryStateMap = { + where: parseAsStringEncoded, + whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), +}; + function DBServiceMapPage() { const brandName = useBrandDisplayName(); @@ -58,6 +73,9 @@ function DBServiceMapPage() { const [sourceId, setSourceId] = useQueryState('source'); const [isCreateSourceModalOpen, setIsCreateSourceModalOpen] = useState(false); + const [searchedConfig, setSearchedConfig] = + useQueryStates(searchQueryStateMap); + const [displayedTimeInputValue, setDisplayedTimeInputValue] = useState(DEFAULT_INTERVAL); @@ -78,9 +96,12 @@ function DBServiceMapPage() { ) ?? defaultSource) : defaultSource; - const { control } = useForm({ + const { control, handleSubmit, setValue } = useForm({ values: { source: source?.id, + where: searchedConfig.where ?? '', + whereLanguage: + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', }, }); @@ -92,6 +113,15 @@ function DBServiceMapPage() { } }, [watchedSource, sourceId, setSourceId]); + const sourceTableConnection = useMemo(() => tcFromSource(source), [source]); + + const onSubmit = useCallback(() => { + onSearch(displayedTimeInputValue); + handleSubmit(({ where, whereLanguage }) => { + setSearchedConfig({ where, whereLanguage }); + })(); + }, [handleSubmit, setSearchedConfig, displayedTimeInputValue, onSearch]); + const [samplingFactor, setSamplingFactor] = useQueryState( 'samplingFactor', parseAsInteger.withDefault(10), @@ -179,7 +209,7 @@ function DBServiceMapPage() { style={{ display: 'flex', flexDirection: 'column', height: '100vh' }} > {head} - + Service Map + + + setValue('whereLanguage', lang, { shouldDirty: true }) + } + enableHotkey + size="xs" + data-testid="service-map-search-input" + dateRange={searchedTimeRange} + sourceId={source?.id} + lucenePlaceholder="Filter spans w/ Lucene (ex. ServiceName:my-service)" + sqlPlaceholder="SQL WHERE to filter spans (ex. ServiceName = 'my-service')" + /> + ) : null; diff --git a/packages/app/src/components/ServiceMap/ServiceMap.tsx b/packages/app/src/components/ServiceMap/ServiceMap.tsx index 29dc7030e0..24b325ac06 100644 --- a/packages/app/src/components/ServiceMap/ServiceMap.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMap.tsx @@ -246,6 +246,8 @@ interface ServiceMapProps { dateRange: [Date, Date]; samplingFactor?: number; isSingleTrace?: boolean; + where?: string; + whereLanguage?: 'sql' | 'lucene'; } export default function ServiceMap({ @@ -254,6 +256,8 @@ export default function ServiceMap({ dateRange, samplingFactor = 1, isSingleTrace, + where, + whereLanguage, }: ServiceMapProps) { const { isLoading, @@ -264,6 +268,8 @@ export default function ServiceMap({ source: traceTableSource, dateRange, samplingFactor, + where, + whereLanguage, }); useEffect(() => { diff --git a/packages/app/src/hooks/useServiceMap.tsx b/packages/app/src/hooks/useServiceMap.tsx index 5fc6639d04..21eb30af5a 100644 --- a/packages/app/src/hooks/useServiceMap.tsx +++ b/packages/app/src/hooks/useServiceMap.tsx @@ -22,12 +22,16 @@ async function getServiceMapQuery({ traceId, metadata, samplingFactor, + where, + whereLanguage, }: { source: TTraceSource; dateRange: [Date, Date]; traceId?: string; metadata: Metadata; samplingFactor: number; + where?: string; + whereLanguage?: 'sql' | 'lucene'; }) { // Don't sample if we're looking for a specific trace const effectiveSamplingLevel = traceId ? 1 : samplingFactor; @@ -37,6 +41,11 @@ async function getServiceMapQuery({ connection: source.connection, dateRange, timestampValueExpression: source.timestampValueExpression, + ...(source.implicitColumnExpression != null + ? { implicitColumnExpression: source.implicitColumnExpression } + : {}), + where: where || '', + whereLanguage: (whereLanguage ?? 'lucene') as 'sql' | 'lucene', filters: [ // Sample a subset of traces, for performance in the following join { @@ -91,7 +100,6 @@ async function getServiceMapQuery({ condition: `${source.spanKindExpression} IN ('Server', 'Consumer', 'SPAN_KIND_SERVER', 'SPAN_KIND_CONSUMER')`, }, ], - where: '', }, metadata, source.querySettings, @@ -106,7 +114,6 @@ async function getServiceMapQuery({ condition: `${source.spanKindExpression} IN ('Client', 'Producer', 'SPAN_KIND_CLIENT', 'SPAN_KIND_PRODUCER')`, }, ], - where: '', }, metadata, source.querySettings, @@ -246,17 +253,29 @@ export default function useServiceMap({ dateRange, traceId, samplingFactor, + where, + whereLanguage, }: { source: TTraceSource; dateRange: [Date, Date]; traceId?: string; samplingFactor: number; + where?: string; + whereLanguage?: 'sql' | 'lucene'; }) { const client = useClickhouseClient(); const metadata = useMetadataWithSettings(); return useQuery({ - queryKey: ['serviceMapData', traceId, source, dateRange, samplingFactor], + queryKey: [ + 'serviceMapData', + traceId, + source, + dateRange, + samplingFactor, + where, + whereLanguage, + ], queryFn: async ({ signal }) => { const query = await getServiceMapQuery({ source, @@ -264,6 +283,8 @@ export default function useServiceMap({ traceId, metadata, samplingFactor, + where, + whereLanguage, }); const data = await client From ef5a0e43da94161b8687807ffbb25a338e35bc6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 21 May 2026 19:25:55 +0000 Subject: [PATCH 2/4] feat: add service name MultiSelect filter to Service Map Add a searchable MultiSelect dropdown that lets users filter the service map by specific service names. The dropdown is populated with distinct service names from the trace source via useGetKeyValues. Changes: - Add MultiSelect with service name options fetched from ClickHouse - Wire selected services as SQL IN filter in useServiceMap's baseCTEConfig - Persist selected services in URL via nuqs parseAsArrayOf for shareable links - Place MultiSelect alongside SearchWhereInput in the filter bar - Update SearchWhereInput placeholders to suggest attribute-level filters since service filtering is now handled by the dedicated dropdown Co-authored-by: Mike Shi --- packages/app/src/DBServiceMapPage.tsx | 90 +++++++++++++++++-- .../src/components/ServiceMap/ServiceMap.tsx | 3 + packages/app/src/hooks/useServiceMap.tsx | 18 ++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/packages/app/src/DBServiceMapPage.tsx b/packages/app/src/DBServiceMapPage.tsx index 6ebcaed7ba..21de3cec45 100644 --- a/packages/app/src/DBServiceMapPage.tsx +++ b/packages/app/src/DBServiceMapPage.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import dynamic from 'next/dynamic'; import Head from 'next/head'; import { + parseAsArrayOf, parseAsInteger, + parseAsString, parseAsStringEnum, useQueryState, useQueryStates, @@ -10,7 +12,16 @@ import { import { useForm, useWatch } from 'react-hook-form'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types'; -import { Box, Button, Group, Modal, Slider, Text } from '@mantine/core'; +import { + Box, + Button, + Flex, + Group, + Modal, + MultiSelect, + Slider, + Text, +} from '@mantine/core'; import { IconConnection } from '@tabler/icons-react'; import EmptyState from '@/components/EmptyState'; @@ -18,6 +29,7 @@ import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; import { IS_LOCAL_MODE } from '@/config'; +import { useGetKeyValues } from '@/hooks/useMetadata'; import { withAppNav } from '@/layout'; import { parseAsStringEncoded } from '@/utils/queryParsers'; @@ -64,6 +76,7 @@ const defaultTimeRange = parseTimeQuery(DEFAULT_INTERVAL, false) as [ const searchQueryStateMap = { where: parseAsStringEncoded, whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), + services: parseAsArrayOf(parseAsString), }; function DBServiceMapPage() { @@ -115,10 +128,54 @@ function DBServiceMapPage() { const sourceTableConnection = useMemo(() => tcFromSource(source), [source]); + const serviceNameKey = source?.serviceNameExpression ?? 'ServiceName'; + const serviceNamesChartConfig = useMemo( + () => + source + ? { + from: source.from, + connection: source.connection, + timestampValueExpression: source.timestampValueExpression, + where: '', + select: '', + dateRange: searchedTimeRange, + } + : undefined, + [source, searchedTimeRange], + ); + const { data: serviceNameValues, isLoading: isServiceNamesLoading } = + useGetKeyValues( + { + chartConfig: serviceNamesChartConfig, + keys: [serviceNameKey], + disableRowLimit: true, + limit: 10000, + }, + { enabled: !!source }, + ); + const serviceNameOptions = useMemo( + () => + (serviceNameValues?.[0]?.value ?? []) + .map(v => String(v)) + .sort((a, b) => a.localeCompare(b)), + [serviceNameValues], + ); + + const selectedServices = searchedConfig.services ?? []; + const setSelectedServices = useCallback( + (values: string[]) => { + setSearchedConfig(prev => ({ + ...prev, + services: values.length > 0 ? values : null, + })); + }, + [setSearchedConfig], + ); + const onSubmit = useCallback(() => { onSearch(displayedTimeInputValue); handleSubmit(({ where, whereLanguage }) => { - setSearchedConfig({ where, whereLanguage }); + setSearchedConfig(prev => ({ ...prev, where, whereLanguage })); })(); }, [handleSubmit, setSearchedConfig, displayedTimeInputValue, onSearch]); @@ -245,7 +302,24 @@ function DBServiceMapPage() { /> - + + - + 0 ? selectedServices : undefined + } /> ) : null; diff --git a/packages/app/src/components/ServiceMap/ServiceMap.tsx b/packages/app/src/components/ServiceMap/ServiceMap.tsx index 24b325ac06..4f36493599 100644 --- a/packages/app/src/components/ServiceMap/ServiceMap.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMap.tsx @@ -248,6 +248,7 @@ interface ServiceMapProps { isSingleTrace?: boolean; where?: string; whereLanguage?: 'sql' | 'lucene'; + serviceNames?: string[]; } export default function ServiceMap({ @@ -258,6 +259,7 @@ export default function ServiceMap({ isSingleTrace, where, whereLanguage, + serviceNames, }: ServiceMapProps) { const { isLoading, @@ -270,6 +272,7 @@ export default function ServiceMap({ samplingFactor, where, whereLanguage, + serviceNames, }); useEffect(() => { diff --git a/packages/app/src/hooks/useServiceMap.tsx b/packages/app/src/hooks/useServiceMap.tsx index 21eb30af5a..8d7e738ff3 100644 --- a/packages/app/src/hooks/useServiceMap.tsx +++ b/packages/app/src/hooks/useServiceMap.tsx @@ -24,6 +24,7 @@ async function getServiceMapQuery({ samplingFactor, where, whereLanguage, + serviceNames, }: { source: TTraceSource; dateRange: [Date, Date]; @@ -32,6 +33,7 @@ async function getServiceMapQuery({ samplingFactor: number; where?: string; whereLanguage?: 'sql' | 'lucene'; + serviceNames?: string[]; }) { // Don't sample if we're looking for a specific trace const effectiveSamplingLevel = traceId ? 1 : samplingFactor; @@ -64,6 +66,18 @@ async function getServiceMapQuery({ }, ] : []), + // Optionally filter by selected service names + ...(serviceNames && serviceNames.length > 0 + ? [ + { + type: 'sql' as const, + condition: SqlString.format('?? IN (?)', [ + SqlString.raw(source.serviceNameExpression ?? 'ServiceName'), + serviceNames, + ]), + }, + ] + : []), ], select: [ { @@ -255,6 +269,7 @@ export default function useServiceMap({ samplingFactor, where, whereLanguage, + serviceNames, }: { source: TTraceSource; dateRange: [Date, Date]; @@ -262,6 +277,7 @@ export default function useServiceMap({ samplingFactor: number; where?: string; whereLanguage?: 'sql' | 'lucene'; + serviceNames?: string[]; }) { const client = useClickhouseClient(); const metadata = useMetadataWithSettings(); @@ -275,6 +291,7 @@ export default function useServiceMap({ samplingFactor, where, whereLanguage, + serviceNames, ], queryFn: async ({ signal }) => { const query = await getServiceMapQuery({ @@ -285,6 +302,7 @@ export default function useServiceMap({ samplingFactor, where, whereLanguage, + serviceNames, }); const data = await client From 9b9931ab0ae8f218afafe6ac0acf7c6b49a12e70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 21 May 2026 19:31:00 +0000 Subject: [PATCH 3/4] fix: use correct SqlString format for service name IN clause SqlString.raw() returns an object for column identifiers - use '?' placeholder (not '??') so it resolves to the raw string value correctly. Co-authored-by: Mike Shi --- packages/app/src/hooks/useServiceMap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/hooks/useServiceMap.tsx b/packages/app/src/hooks/useServiceMap.tsx index 8d7e738ff3..1121a0e15a 100644 --- a/packages/app/src/hooks/useServiceMap.tsx +++ b/packages/app/src/hooks/useServiceMap.tsx @@ -71,7 +71,7 @@ async function getServiceMapQuery({ ? [ { type: 'sql' as const, - condition: SqlString.format('?? IN (?)', [ + condition: SqlString.format('? IN (?)', [ SqlString.raw(source.serviceNameExpression ?? 'ServiceName'), serviceNames, ]), From eb504083c07b92013c1f016d4717392d23e3e0bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 04:20:46 +0000 Subject: [PATCH 4/4] feat: show inbound and outbound neighbors in service name filter Move the service name filter from the CTE level (span-level filtering) to the outer query (post-JOIN filtering). The filter now matches edges where either the server OR client service is in the selected set, so selecting a service shows both its inbound callers and outbound callees in the service graph. Co-authored-by: Mike Shi --- packages/app/src/hooks/useServiceMap.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/app/src/hooks/useServiceMap.tsx b/packages/app/src/hooks/useServiceMap.tsx index 1121a0e15a..33ca2ae43f 100644 --- a/packages/app/src/hooks/useServiceMap.tsx +++ b/packages/app/src/hooks/useServiceMap.tsx @@ -66,18 +66,6 @@ async function getServiceMapQuery({ }, ] : []), - // Optionally filter by selected service names - ...(serviceNames && serviceNames.length > 0 - ? [ - { - type: 'sql' as const, - condition: SqlString.format('? IN (?)', [ - SqlString.raw(source.serviceNameExpression ?? 'ServiceName'), - serviceNames, - ]), - }, - ] - : []), ], select: [ { @@ -134,6 +122,16 @@ async function getServiceMapQuery({ ), ]); + const serviceNameInList = serviceNames?.length + ? { UNSAFE_RAW_SQL: serviceNames.map(s => SqlString.escape(s)).join(', ') } + : null; + const serviceNameFilter = serviceNameInList + ? chSql`AND ( + ServerSpans.serviceName IN (${serviceNameInList}) + OR ClientSpans.serviceName IN (${serviceNameInList}) + )` + : chSql``; + // Left join to support services which receive requests from clients that are not instrumented. // Ordering helps ensure stable graph layout. return chSql` @@ -150,6 +148,7 @@ async function getServiceMapQuery({ ON ServerSpans.traceId = ClientSpans.traceId AND ServerSpans.parentSpanId = ClientSpans.spanId WHERE (ClientSpans.serviceName IS NULL OR ServerSpans.serviceName != ClientSpans.serviceName) + ${serviceNameFilter} GROUP BY serverServiceName, serverStatusCode, clientServiceName ORDER BY serverServiceName, serverStatusCode, clientServiceName `;