// 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 (
);
};
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 ? (
<>
| 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)}
|
)}
{originalPoint.duration != null &&
Number(originalPoint.duration) > 0 && (
| 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;