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', 'SPAN_KIND_SERVER', 'SPAN_KIND_CONSUMER')`, }, ], where: '', }, metadata, source.querySettings, ), renderChartConfig( { ...baseCTEConfig, filters: [ ...baseCTEConfig.filters, { type: 'sql', condition: `${source.spanKindExpression} IN ('Client', 'Producer', 'SPAN_KIND_CLIENT', 'SPAN_KIND_PRODUCER')`, }, ], where: '', }, metadata, source.querySettings, ), ]); // 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, }); }