diff --git a/packages/app/src/DBServiceMapPage.tsx b/packages/app/src/DBServiceMapPage.tsx index b9d209410f..21de3cec45 100644 --- a/packages/app/src/DBServiceMapPage.tsx +++ b/packages/app/src/DBServiceMapPage.tsx @@ -1,15 +1,37 @@ -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 { + parseAsArrayOf, + parseAsInteger, + parseAsString, + 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 { + Box, + Button, + Flex, + Group, + Modal, + MultiSelect, + 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 { useGetKeyValues } from '@/hooks/useMetadata'; import { withAppNav } from '@/layout'; +import { parseAsStringEncoded } from '@/utils/queryParsers'; import OnboardingModal from './components/OnboardingModal'; import ServiceMap from './components/ServiceMap/ServiceMap'; @@ -51,6 +73,12 @@ const defaultTimeRange = parseTimeQuery(DEFAULT_INTERVAL, false) as [ Date, ]; +const searchQueryStateMap = { + where: parseAsStringEncoded, + whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), + services: parseAsArrayOf(parseAsString), +}; + function DBServiceMapPage() { const brandName = useBrandDisplayName(); @@ -58,6 +86,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 +109,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 +126,59 @@ function DBServiceMapPage() { } }, [watchedSource, sourceId, setSourceId]); + 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(prev => ({ ...prev, where, whereLanguage })); + })(); + }, [handleSubmit, setSearchedConfig, displayedTimeInputValue, onSearch]); + const [samplingFactor, setSamplingFactor] = useQueryState( 'samplingFactor', parseAsInteger.withDefault(10), @@ -179,7 +266,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. http.method:GET)" + sqlPlaceholder="SQL WHERE to filter spans (ex. Duration > 1000000)" + minWidth="min(500px, 100%)" + /> + 0 ? selectedServices : undefined + } /> ) : null; diff --git a/packages/app/src/components/ServiceMap/ServiceMap.tsx b/packages/app/src/components/ServiceMap/ServiceMap.tsx index 29dc7030e0..4f36493599 100644 --- a/packages/app/src/components/ServiceMap/ServiceMap.tsx +++ b/packages/app/src/components/ServiceMap/ServiceMap.tsx @@ -246,6 +246,9 @@ interface ServiceMapProps { dateRange: [Date, Date]; samplingFactor?: number; isSingleTrace?: boolean; + where?: string; + whereLanguage?: 'sql' | 'lucene'; + serviceNames?: string[]; } export default function ServiceMap({ @@ -254,6 +257,9 @@ export default function ServiceMap({ dateRange, samplingFactor = 1, isSingleTrace, + where, + whereLanguage, + serviceNames, }: ServiceMapProps) { const { isLoading, @@ -264,6 +270,9 @@ export default function ServiceMap({ source: traceTableSource, dateRange, samplingFactor, + where, + whereLanguage, + serviceNames, }); useEffect(() => { diff --git a/packages/app/src/hooks/useServiceMap.tsx b/packages/app/src/hooks/useServiceMap.tsx index 5fc6639d04..33ca2ae43f 100644 --- a/packages/app/src/hooks/useServiceMap.tsx +++ b/packages/app/src/hooks/useServiceMap.tsx @@ -22,12 +22,18 @@ async function getServiceMapQuery({ traceId, metadata, samplingFactor, + where, + whereLanguage, + serviceNames, }: { source: TTraceSource; dateRange: [Date, Date]; traceId?: string; metadata: Metadata; 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; @@ -37,6 +43,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 +102,6 @@ async function getServiceMapQuery({ condition: `${source.spanKindExpression} IN ('Server', 'Consumer', 'SPAN_KIND_SERVER', 'SPAN_KIND_CONSUMER')`, }, ], - where: '', }, metadata, source.querySettings, @@ -106,13 +116,22 @@ async function getServiceMapQuery({ condition: `${source.spanKindExpression} IN ('Client', 'Producer', 'SPAN_KIND_CLIENT', 'SPAN_KIND_PRODUCER')`, }, ], - where: '', }, metadata, source.querySettings, ), ]); + 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` @@ -129,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 `; @@ -246,17 +266,32 @@ export default function useServiceMap({ dateRange, traceId, samplingFactor, + where, + whereLanguage, + serviceNames, }: { source: TTraceSource; dateRange: [Date, Date]; traceId?: string; samplingFactor: number; + where?: string; + whereLanguage?: 'sql' | 'lucene'; + serviceNames?: string[]; }) { const client = useClickhouseClient(); const metadata = useMetadataWithSettings(); return useQuery({ - queryKey: ['serviceMapData', traceId, source, dateRange, samplingFactor], + queryKey: [ + 'serviceMapData', + traceId, + source, + dateRange, + samplingFactor, + where, + whereLanguage, + serviceNames, + ], queryFn: async ({ signal }) => { const query = await getServiceMapQuery({ source, @@ -264,6 +299,9 @@ export default function useServiceMap({ traceId, metadata, samplingFactor, + where, + whereLanguage, + serviceNames, }); const data = await client