// Copyright 2022 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {useEffect, useMemo, useState} from 'react'; import {Icon} from '@iconify/react'; import {AnimatePresence, motion} from 'framer-motion'; import {useQueryState} from 'nuqs'; import { Label, MetricsSample, MetricsSeries as MetricsSeriesPb, QueryServiceClient, } from '@parca/client'; import { DateTimeRange, MetricsGraphSkeleton, TextWithTooltip, useParcaContext, } from '@parca/components'; import {Query} from '@parca/parser'; import {TEST_IDS, testId} from '@parca/test-utils'; import {capitalizeOnlyFirstLetter, formatDate, timePattern, valueFormatter} from '@parca/utilities'; import {MergedProfileSelection, ProfileSelection} from '..'; import MetricsGraph, {ContextMenuItemOrSubmenu, Series, SeriesPoint} from '../MetricsGraph'; import {useMetricsGraphDimensions} from '../MetricsGraph/useMetricsGraphDimensions'; import {intParam} from '../hooks/urlParsers'; import {getStepCountFromScreenWidth, useQueryRange} from './hooks/useQueryRange'; const createProfileContextMenuItems = ( addLabelMatcher: ( labels: {key: string; value: string} | Array<{key: string; value: string}> ) => void, data: MetricsSeriesPb[] // The original MetricsSeriesPb[] data ): ContextMenuItemOrSubmenu[] => { return [ { id: 'focus-on-single-series', label: 'Focus only on this series', icon: 'ph:star', onClick: (closestPoint, _series) => { if (closestPoint != null && data.length > 0 && data[closestPoint.seriesIndex] != null) { const originalSeriesData = data[closestPoint.seriesIndex]; if (originalSeriesData.labelset?.labels != null) { const labels = originalSeriesData.labelset.labels.filter( (label: Label) => label.name !== '__name__' ); const labelsToAdd = labels.map((label: Label) => ({ key: label.name, value: label.value, })); addLabelMatcher(labelsToAdd); } } }, }, { id: 'add-to-query', label: 'Add to query', icon: 'material-symbols:add', createDynamicItems: (closestPoint, _series) => { const noLabelsAvailable = [ { id: 'no-labels-available', label: 'No labels available', icon: 'ph:warning', disabled: () => true, onClick: () => {}, // No-op for disabled item }, ]; if (closestPoint == null || data.length === 0 || data[closestPoint.seriesIndex] == null) { return noLabelsAvailable; } const originalSeriesData = data[closestPoint.seriesIndex]; if (originalSeriesData.labelset?.labels == null) { return noLabelsAvailable; } const labels = originalSeriesData.labelset.labels.filter( (label: Label) => label.name !== '__name__' ); if (labels.length === 0) { return noLabelsAvailable; } return labels.map((label: Label) => ({ id: `add-label-${label.name}`, label: (
{`${label.name}="${label.value}"`}
), onClick: () => { addLabelMatcher({ key: label.name, value: label.value, }); }, })); }, }, ]; }; const transformMetricsData = (data: MetricsSeriesPb[]): Series[] => { const series = data.reduce((agg: Series[], s: MetricsSeriesPb) => { if (s.labelset !== undefined) { // Generate ID from sorted labelsets const labels = s.labelset.labels ?? []; const sortedLabels = labels .filter(label => label.name !== '__name__') // Exclude __name__ from ID generation .sort((a, b) => a.name.localeCompare(b.name)); const id = sortedLabels.map(label => `${label.name}=${label.value}`).join(','); agg.push({ id: id !== '' ? id : 'default', // fallback to 'default' if no labels values: s.samples.reduce>((agg, d: MetricsSample) => { if (d.timestamp !== undefined && d.valuePerSecond !== undefined) { const timestampMs = Number(d.timestamp.seconds) * 1000 + d.timestamp.nanos / 1_000_000; agg.push([timestampMs, d.valuePerSecond]); } return agg; }, []), }); } return agg; }, []); return series; }; interface ProfileMetricsEmptyStateProps { message: string; } const ErrorContent = ({errorMessage}: {errorMessage: string}): JSX.Element => { return (
{errorMessage}
); }; export const ProfileMetricsEmptyState = ({message}: ProfileMetricsEmptyStateProps): JSX.Element => { return (

{message}

); }; interface ProfileMetricsGraphProps { queryClient: QueryServiceClient; queryExpression: string; profile: ProfileSelection | null; from: number; to: number; sumByLoading: boolean; sumBy: string[]; setTimeRange: (range: DateTimeRange) => void; addLabelMatcher: ( labels: {key: string; value: string} | Array<{key: string; value: string}> ) => void; onPointClick: ( timestamp: bigint, labels: Label[], queryExpression: string, duration: number ) => void; comparing?: boolean; } const ProfileMetricsGraph = ({ queryClient, queryExpression, profile, from, to, setTimeRange, addLabelMatcher, onPointClick, comparing = false, sumBy, }: ProfileMetricsGraphProps): JSX.Element => { const [rawStepCount] = useQueryState( 'step_count', intParam.withDefault(getStepCountFromScreenWidth(10)) ); // Clamp step count so the step duration is at least 1 second as we don't have this enforced server-side anymore. const stepCount = useMemo(() => { const maxForOneSecond = Math.floor((to - from) / 1000); return Math.min(rawStepCount, maxForOneSecond); }, [rawStepCount, from, to]); const { isLoading: metricsGraphLoading, response, error, } = useQueryRange( queryClient, queryExpression, from, to, sumBy, stepCount, queryExpression === '' ); const {onError, perf, authenticationErrorMessage, isDarkMode, timezone, profileExplorer} = useParcaContext(); const {width, height, margin, heightStyle} = useMetricsGraphDimensions( comparing, profileExplorer?.metricsGraph.height ); const [showAllSeriesForResponse, setShowAllSeriesForResponse] = useState( null ); useEffect(() => { if (error !== null) { onError?.(error); } }, [error, onError]); // Reset showAllSeriesForResponse when response changes to free memory useEffect(() => { setShowAllSeriesForResponse(null); }, [response]); useEffect(() => { if (response === null) { return; } perf?.markInteraction('Metrics graph render', response.series[0].samples.length); }, [perf, response]); const [originalSeries, {isTrimmed, beforeTrim, afterTrim}] = useMemo(() => { if (response?.series != null) { // Check if user wants ALL series for THIS specific response const userWantsAllForThisResponse = showAllSeriesForResponse === response; const maxSeriesLimit = 100; // Limit the number of series to maxSeriesLimit to avoid performance issues (unless user opts to show all) if (response.series.length > maxSeriesLimit && !userWantsAllForThisResponse) { // Select top `maxSeriesLimit` series based on their max value (to catch series with large spikes) const seriesWithMaxValue = response.series.map(series => { const maxValue = series.samples.reduce((max, sample) => { const value = sample.valuePerSecond ?? 0; return value > max ? value : max; }, 0); return {series, maxValue}; }); // Sort by max value descending and take top `maxSeriesLimit` series const topSeries = seriesWithMaxValue .sort((a, b) => b.maxValue - a.maxValue) .slice(0, maxSeriesLimit) .map(item => item.series); return [ topSeries, {isTrimmed: true, beforeTrim: response.series.length, afterTrim: maxSeriesLimit}, ]; } return [response.series, {isTrimmed: false, beforeTrim: 0, afterTrim: 0}]; } return [null, {isTrimmed: false, beforeTrim: 0, afterTrim: 0}]; }, [response, showAllSeriesForResponse]); const selectedPoint = useMemo((): SeriesPoint | null => { if (profile !== null && profile instanceof MergedProfileSelection) { // Iterate over the series and find the series index that matches all // labels of the profile selection. We specifically need the index // because that's what the SeriesPoint interface expects. const seriesIndex = originalSeries?.findIndex(s => { return s.labelset?.labels?.every(label => { return profile.query.matchers.some(matcher => { return matcher.key === label.name && matcher.value === label.value; }); }); }); // if we found a series, return the point that matches the from/to timestamp exactly (in millisecond precision) if ( seriesIndex !== undefined && seriesIndex !== -1 && originalSeries != null && originalSeries[seriesIndex] != null ) { const series = originalSeries[seriesIndex]; const pointIndex = series.samples.findIndex(sample => { return ( sample.timestamp?.seconds === BigInt(profile.mergeFrom / 1_000_000_000n) && sample.timestamp?.nanos === Number(profile.mergeFrom % 1_000_000_000n) ); }); if (pointIndex !== -1) { return { seriesIndex, pointIndex, }; } } return null; } return null; }, [profile, originalSeries]); const transformedSeries = useMemo(() => { return originalSeries != null ? transformMetricsData(originalSeries) : []; }, [originalSeries]); const contextMenuItems = useMemo(() => { return originalSeries != null ? createProfileContextMenuItems(addLabelMatcher, originalSeries) : []; }, [originalSeries, addLabelMatcher]); const dataAvailable = originalSeries !== null && originalSeries !== undefined && originalSeries?.length > 0; const {sampleUnit, sampleType, yAxisLabel, yAxisUnit} = useMemo(() => { let sampleUnit = ''; let sampleType = ''; if (dataAvailable) { if ( originalSeries?.every((val, i, arr) => val?.sampleType?.unit === arr[0]?.sampleType?.unit) ) { sampleUnit = originalSeries[0]?.sampleType?.unit ?? ''; sampleType = originalSeries[0]?.sampleType?.type ?? ''; } if (sampleUnit === '') { const profileType = Query.parse(queryExpression).profileType(); sampleUnit = profileType.sampleUnit; sampleType = profileType.sampleType; } } // Calculate axis labels based on profile data const isDeltaType = profile !== null ? (profile as MergedProfileSelection)?.query.profType.delta : false; let yAxisLabel = sampleUnit; let yAxisUnit = sampleUnit; if (isDeltaType) { if (sampleUnit === 'nanoseconds') { if (sampleType === 'cpu') { yAxisLabel = 'CPU Cores'; yAxisUnit = ''; } if (sampleType === 'cuda' || sampleType === 'gpu_time') { yAxisLabel = 'GPU Time'; } if (sampleType === 'gpu_kernel_time') { yAxisLabel = 'GPU Kernel Time'; } if (sampleType === 'gpu_stall_time') { yAxisLabel = 'GPU Stall Time'; } } if (sampleUnit === 'bytes') { yAxisLabel = 'Bytes per Second'; } } return {sampleUnit, sampleType, yAxisLabel, yAxisUnit}; }, [dataAvailable, originalSeries, queryExpression, profile]); const loading = metricsGraphLoading; // Handle errors after all hooks have been called if (!metricsGraphLoading && error !== null) { if (authenticationErrorMessage !== undefined && error.code === 'UNAUTHENTICATED') { return ; } return ; } return ( {isTrimmed ? (
Note: Showing only {afterTrim} of {new Intl.NumberFormat().format(beforeTrim)} series for performance reasons. Please narrow your query to view more.
) : null} {loading ? ( ) : dataAvailable ? ( { // Use original data for both series and point if (originalSeries?.[closestPoint.seriesIndex] != null) { const originalSeriesData = originalSeries[closestPoint.seriesIndex]; const originalPoint = originalSeriesData.samples[closestPoint.pointIndex]; if (originalPoint.timestamp != null && originalPoint.valuePerSecond !== undefined) { const timestampNanos = originalPoint.timestamp.seconds * 1_000_000_000n + BigInt(originalPoint.timestamp.nanos); onPointClick( timestampNanos, // Convert to number to match interface originalSeriesData.labelset?.labels ?? [], queryExpression, Number(originalPoint.duration ?? 0) // Convert bigint to number ); } } }} renderTooltipContent={(seriesIndex: number, pointIndex: number) => { if (originalSeries?.[seriesIndex]?.samples?.[pointIndex] != null) { const originalSeriesData = originalSeries[seriesIndex]; const originalPoint = originalSeriesData.samples[pointIndex]; if (originalPoint.timestamp != null && originalPoint.valuePerSecond !== undefined) { const timestampMs = Number(originalPoint.timestamp.seconds) * 1000 + originalPoint.timestamp.nanos / 1_000_000; const labels = originalSeriesData.labelset?.labels ?? []; const nameLabel = labels.find(e => e.name === '__name__'); const highlightedNameLabel = nameLabel ?? {name: '', value: ''}; const isDeltaType = profile !== null ? (profile as MergedProfileSelection)?.query.profType.delta : false; return (
{highlightedNameLabel.value} {isDeltaType ? ( <> ) : ( )} {originalPoint.duration != null && Number(originalPoint.duration) > 0 && ( )}
Per Second {valueFormatter( originalPoint.valuePerSecond, sampleUnit === 'nanoseconds' && sampleType === 'cpu' ? 'CPU Cores' : sampleUnit, 5 )}
Total {valueFormatter(originalPoint.value ?? 0, sampleUnit, 2)}
Value {valueFormatter(originalPoint.valuePerSecond, sampleUnit, 5)}
Duration {valueFormatter( Number(originalPoint.duration.toString()), 'nanoseconds', 2 )}
At {formatDate( new Date(timestampMs), timePattern(timezone as string), timezone )}
{labels .filter((label: Label) => label.name !== '__name__') .map((label: Label) => (
))}
Right click to add labels to query.
); } } return null; }} yAxisLabel={yAxisLabel} yAxisUnit={yAxisUnit} height={height} width={width} margin={margin} contextMenuItems={contextMenuItems} /> ) : ( )}
); }; export default ProfileMetricsGraph;