// 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, useRef} from 'react'; import {createParser, useQueryState} from 'nuqs'; import {LabelSet, QueryRequest_ReportType, QueryServiceClient} from '@parca/client'; import {Button, useParcaContext} from '@parca/components'; import {Matcher, MatcherTypes, ProfileType, Query} from '@parca/parser'; import {TimeUnits, formatDate, formatDuration} from '@parca/utilities'; import ProfileFlameGraph, {validateFlameChartQuery} from '../ProfileFlameGraph'; import {boundsFromProfileSource} from '../ProfileFlameGraph/FlameGraphArrow/utils'; import { MergedProfileSource, ProfileSource, isMergedProfileSource, timeFormat, } from '../ProfileSource'; import {useProfileFilters} from '../ProfileView/components/ProfileFilters/useProfileFilters'; import type {SamplesData} from '../ProfileView/types/visualization'; import {flamechartDimensionParser} from '../hooks/urlParsers'; import {useQuery} from '../useQuery'; import {NumberDuo} from '../utils'; import {SamplesStrip} from './SamplesStrips'; interface SelectedTimeframe { labels: LabelSet; bounds: NumberDuo; } const timeframeParser = createParser({ parse: (value: string) => { try { const [labelPart, boundsPart] = value.split('|'); if (labelPart != null && boundsPart != null) { const labels = labelPart.split(',').map(labelStr => { const [name, ...rest] = labelStr.split(':'); return {name, value: rest.join(':')}; }); const [startMs, endMs] = boundsPart.split(',').map(Number); if (labels.length > 0 && !isNaN(startMs) && !isNaN(endMs)) { return { labels: {labels}, bounds: [startMs, endMs] as NumberDuo, }; } } } catch { // Ignore parsing errors } return null; }, serialize: (value: SelectedTimeframe) => { const labelsStr = value.labels.labels.map(l => `${l.name}:${l.value}`).join(','); return `${labelsStr}|${value.bounds[0]},${value.bounds[1]}`; }, }).withOptions({history: 'replace'}); interface ProfileFlameChartProps { samplesData?: SamplesData; queryClient: QueryServiceClient; profileSource: ProfileSource; width: number; total: bigint; filtered: bigint; profileType?: ProfileType; isHalfScreen: boolean; metadataMappingFiles?: string[]; metadataLoading?: boolean; onSwitchToFifteenMinutes?: () => void; } // Helper to create a filtered profile source with narrowed time bounds // and dimension label matchers from the selected strip. const createFilteredProfileSource = ( profileSource: ProfileSource, selectedTimeframe: {labels: LabelSet; bounds: NumberDuo} ): ProfileSource | null => { if (!isMergedProfileSource(profileSource)) { return null; } // The bounds are in milliseconds, convert to nanoseconds for the profile source // Round to integers since BigInt requires integer values const mergeFrom = BigInt(Math.round(selectedTimeframe.bounds[0])) * 1_000_000n; const mergeTo = BigInt(Math.round(selectedTimeframe.bounds[1])) * 1_000_000n; // Add dimension labels as additional matchers to the query const dimensionMatchers = selectedTimeframe.labels.labels.map( l => new Matcher(l.name, MatcherTypes.MatchEqual, l.value) ); const query = new Query( profileSource.query.profType, [...profileSource.query.matchers, ...dimensionMatchers], '' ); return new MergedProfileSource(mergeFrom, mergeTo, query); }; export const ProfileFlameChart = ({ samplesData, queryClient, profileSource, width, total, filtered, profileType, isHalfScreen, metadataMappingFiles, metadataLoading, onSwitchToFifteenMinutes, }: ProfileFlameChartProps): JSX.Element => { const {enableFlamechartFiltering} = useParcaContext(); const {protoFilters} = useProfileFilters(); const zoomControlsRef = useRef(null); const [selectedTimeframe, setSelectedTimeframe] = useQueryState( 'flamechart_timeframe', timeframeParser ); // Read flamechart dimension from URL state to detect changes const [flamechartDimension] = useQueryState( 'flamechart_dimension', flamechartDimensionParser.withDefault([]) ); // Reset selection when the parent time range (profileSource) changes const timeBoundsKey = boundsFromProfileSource(profileSource).join(','); const prevTimeBoundsKey = useRef(timeBoundsKey); useEffect(() => { if (prevTimeBoundsKey.current !== timeBoundsKey) { prevTimeBoundsKey.current = timeBoundsKey; void setSelectedTimeframe(null); } }, [timeBoundsKey, setSelectedTimeframe]); // Reset selection when the dimension changes const dimensionKey = (flamechartDimension ?? []).join(','); const prevDimensionKey = useRef(dimensionKey); useEffect(() => { if (prevDimensionKey.current !== dimensionKey) { prevDimensionKey.current = dimensionKey; void setSelectedTimeframe(null); } }, [dimensionKey, setSelectedTimeframe]); // Handle timeframe selection from strips const handleSelectedTimeframe = (labels: LabelSet, bounds: NumberDuo | undefined): void => { if (bounds === undefined) { void setSelectedTimeframe(null); } else { void setSelectedTimeframe({labels, bounds}); } }; // Create filtered profile source when selection exists const filteredProfileSource = useMemo(() => { if (selectedTimeframe == null) return null; return createFilteredProfileSource(profileSource, selectedTimeframe); }, [profileSource, selectedTimeframe]); // Query flamechart data only when a strip selection exists const { isLoading: flamechartLoading, response: flamechartResponse, error: flamechartError, } = useQuery( queryClient, filteredProfileSource ?? profileSource, QueryRequest_ReportType.FLAMECHART, { skip: selectedTimeframe == null || filteredProfileSource == null, ...(enableFlamechartFiltering === true ? {protoFilters} : {}), } ); const flamechartArrow = flamechartResponse?.report.oneofKind === 'flamegraphArrow' ? flamechartResponse.report.flamegraphArrow : undefined; const flamechartTotal = flamechartResponse != null ? BigInt(flamechartResponse.total) : total; const flamechartFiltered = flamechartResponse != null ? BigInt(flamechartResponse.filtered) : filtered; // Get time bounds from profile source for the strips const timeBounds = boundsFromProfileSource(profileSource); // Transform samples data for SamplesStrip component const stripsData = useMemo(() => { if (samplesData?.series == null) return {cpus: [], data: [], stepMs: 0}; const cpus = samplesData.series.map(s => s.labelset); const data = samplesData.series.map(s => s.data); const stepMs = samplesData.stepMs ?? 0; return {cpus, data, stepMs}; }, [samplesData?.series, samplesData?.stepMs]); const {isValid, isNonDelta, isDurationTooLong} = validateFlameChartQuery( profileSource as MergedProfileSource ); if (!isValid) { if (isDurationTooLong) { return (
Flame chart is unavailable for queries longer than 15 minutes. Try reducing the time range to 15 minutes or selecting a point in the metrics graph. {onSwitchToFifteenMinutes != null && ( )}
); } const message = isNonDelta ? 'To use the Flame chart, please switch to a Delta profile.' : 'Flame chart is unavailable for this query.'; return (
{message}
); } const hasDimension = (flamechartDimension ?? []).length > 0; const isStripsLoading = metadataLoading === true || !hasDimension || samplesData?.loading === true; if (!hasDimension && metadataLoading !== true) { return (
Select a label in the "Samples group by" dropdown above to view the samples strips.
); } return (
{(isStripsLoading || (stripsData.cpus.length > 0 && stripsData.data.length > 0)) && (
)} {/* Selected timeframe description + zoom controls */} {selectedTimeframe != null && (() => { const labels = selectedTimeframe.labels.labels .map(l => `${l.name} = ${l.value}`) .join(', '); const durationMs = selectedTimeframe.bounds[1] - selectedTimeframe.bounds[0]; const duration = durationMs < 5000 ? `${(durationMs / 1000).toFixed(1)}s` : formatDuration({[TimeUnits.Milliseconds]: durationMs}); const fmt = durationMs < 5000 ? "yyyy-MM-dd HH:mm:ss.SSS '(UTC)'" : timeFormat(); return (
Samples matching {labels} over {duration} from{' '} {formatDate(new Date(selectedTimeframe.bounds[0]), fmt)} to{' '} {formatDate(new Date(selectedTimeframe.bounds[1]), fmt)}
); })()} {/* Flamegraph visualization - only shown when a time range is selected in the strips */} {selectedTimeframe != null && filteredProfileSource != null ? ( {}} zoomControlsRef={zoomControlsRef} /> ) : (
Select a time range in the samples strips above to view the flamechart.
)}
); }; export default ProfileFlameChart;