@parca/profile 0.16.464 → 0.16.466

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,512 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {Fragment, useCallback, useId, useMemo, useRef, useState} from 'react';
15
+
16
+ import * as d3 from 'd3';
17
+ import {pointer} from 'd3-selection';
18
+ import {AnimatePresence, motion} from 'framer-motion';
19
+ import throttle from 'lodash.throttle';
20
+ import {useContextMenu} from 'react-contexify';
21
+
22
+ import {DateTimeRange, MetricsGraphSkeleton, useParcaContext} from '@parca/components';
23
+ import {formatDate, formatForTimespan, getPrecision, valueFormatter} from '@parca/utilities';
24
+
25
+ import MetricsCircle from '../../MetricsCircle';
26
+ import MetricsSeries from '../../MetricsSeries';
27
+ import MetricsContextMenu from '../MetricsContextMenu';
28
+ import MetricsTooltip from '../MetricsTooltip';
29
+ import {type Series} from '../index';
30
+ import {useMetricsGraphDimensions} from '../useMetricsGraphDimensions';
31
+
32
+ interface MetricSeries {
33
+ timestamp: number;
34
+ value: number;
35
+ resource: {
36
+ [key: string]: string;
37
+ };
38
+ attributes: {
39
+ [key: string]: string;
40
+ };
41
+ }
42
+
43
+ interface RawUtilizationMetricsProps {
44
+ data: MetricSeries[];
45
+ addLabelMatcher: (
46
+ labels: {key: string; value: string} | Array<{key: string; value: string}>
47
+ ) => void;
48
+ setTimeRange: (range: DateTimeRange) => void;
49
+ width: number;
50
+ height: number;
51
+ margin: number;
52
+ }
53
+
54
+ interface Props {
55
+ data: MetricSeries[];
56
+ addLabelMatcher: (
57
+ labels: {key: string; value: string} | Array<{key: string; value: string}>
58
+ ) => void;
59
+ setTimeRange: (range: DateTimeRange) => void;
60
+ utilizationMetricsLoading?: boolean;
61
+ }
62
+
63
+ function transformToSeries(data: MetricSeries[]): Series[] {
64
+ const groupedData = data.reduce<Record<string, Series>>((acc, series) => {
65
+ const resourceKey = Object.entries(series.resource)
66
+ .map(([name, value]) => `${name}=${value}`)
67
+ .join(',');
68
+
69
+ if (!Object.hasOwn(acc, resourceKey)) {
70
+ acc[resourceKey] = {
71
+ metric: Object.entries(series.resource).map(([name, value]) => ({name, value})),
72
+ values: [],
73
+ labelset: resourceKey,
74
+ };
75
+ }
76
+
77
+ acc[resourceKey].values.push([series.timestamp, series.value, 0, 0]);
78
+ return acc;
79
+ }, {});
80
+
81
+ // Sort values by timestamp for each series
82
+ return Object.values(groupedData).map(series => ({
83
+ ...series,
84
+ values: series.values.sort((a, b) => a[0] - b[0]),
85
+ }));
86
+ }
87
+
88
+ const RawUtilizationMetrics = ({
89
+ data,
90
+ addLabelMatcher,
91
+ setTimeRange,
92
+ width,
93
+ height,
94
+ margin,
95
+ }: RawUtilizationMetricsProps): JSX.Element => {
96
+ const {timezone} = useParcaContext();
97
+ const graph = useRef(null);
98
+ const [dragging, setDragging] = useState(false);
99
+ const [hovering, setHovering] = useState(false);
100
+ const [relPos, setRelPos] = useState(-1);
101
+ const [pos, setPos] = useState([0, 0]);
102
+ const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean>(false);
103
+ const metricPointRef = useRef(null);
104
+ const idForContextMenu = useId();
105
+
106
+ const lineStroke = '1px';
107
+ const lineStrokeHover = '2px';
108
+
109
+ const graphWidth = width - margin * 1.5 - margin / 2;
110
+
111
+ // Calculate the time range from the data
112
+ const timeExtent = d3.extent(data, d => d.timestamp);
113
+ const from = timeExtent[0] ?? 0;
114
+ const to = timeExtent[1] ?? 0;
115
+
116
+ // Add a small padding (2%) to the time range to avoid points touching the edges
117
+ const timeRange = to - from;
118
+ const paddedFrom = from - timeRange * 0.02;
119
+ const paddedTo = to + timeRange * 0.02;
120
+
121
+ const series = transformToSeries(data);
122
+
123
+ const extentsY = series.map(function (s) {
124
+ return d3.extent(s.values, function (d) {
125
+ return d[1];
126
+ });
127
+ });
128
+
129
+ const minY = d3.min(extentsY, function (d) {
130
+ return d[0];
131
+ });
132
+
133
+ const maxY = d3.max(extentsY, function (d) {
134
+ return d[1];
135
+ });
136
+
137
+ // Setup scales with padded time range
138
+ const xScale = d3.scaleUtc().domain([paddedFrom, paddedTo]).range([0, graphWidth]);
139
+
140
+ const yScale = d3
141
+ .scaleLinear()
142
+ // tslint:disable-next-line
143
+ .domain([minY, maxY] as Iterable<d3.NumberValue>)
144
+ .range([height - margin, 0])
145
+ .nice();
146
+
147
+ const throttledSetPos = throttle(setPos, 20);
148
+
149
+ const onMouseMove = (e: React.MouseEvent<SVGSVGElement | HTMLDivElement, MouseEvent>): void => {
150
+ if (isContextMenuOpen) {
151
+ return;
152
+ }
153
+
154
+ // X/Y coordinate array relative to svg
155
+ const rel = pointer(e);
156
+
157
+ const xCoordinate = rel[0];
158
+ const xCoordinateWithoutMargin = xCoordinate - margin;
159
+ const yCoordinate = rel[1];
160
+ const yCoordinateWithoutMargin = yCoordinate - margin;
161
+
162
+ throttledSetPos([xCoordinateWithoutMargin, yCoordinateWithoutMargin]);
163
+ };
164
+
165
+ const trackVisibility = (isVisible: boolean): void => {
166
+ setIsContextMenuOpen(isVisible);
167
+ };
168
+
169
+ const MENU_ID = `utilizationmetrics-context-menu-${idForContextMenu}`;
170
+
171
+ const {show} = useContextMenu({
172
+ id: MENU_ID,
173
+ });
174
+
175
+ const displayMenu = useCallback(
176
+ (e: React.MouseEvent): void => {
177
+ show({
178
+ event: e,
179
+ });
180
+ },
181
+ [show]
182
+ );
183
+
184
+ const color = d3.scaleOrdinal(d3.schemeCategory10);
185
+
186
+ const l = d3.line(
187
+ d => xScale(d[0]),
188
+ d => yScale(d[1])
189
+ );
190
+
191
+ const highlighted = useMemo(() => {
192
+ // Return the closest point as the highlighted point
193
+ const closestPointPerSeries = series.map(function (s) {
194
+ const distances = s.values.map(d => {
195
+ const x = xScale(d[0]) + margin / 2;
196
+ const y = yScale(d[1]) - margin / 3;
197
+
198
+ return Math.sqrt(Math.pow(pos[0] - x, 2) + Math.pow(pos[1] - y, 2));
199
+ });
200
+
201
+ const pointIndex = d3.minIndex(distances);
202
+ const minDistance = distances[pointIndex];
203
+
204
+ return {
205
+ pointIndex,
206
+ distance: minDistance,
207
+ };
208
+ });
209
+
210
+ const closestSeriesIndex = d3.minIndex(closestPointPerSeries, s => s.distance);
211
+ const pointIndex = closestPointPerSeries[closestSeriesIndex].pointIndex;
212
+ const point = series[closestSeriesIndex].values[pointIndex];
213
+ return {
214
+ seriesIndex: closestSeriesIndex,
215
+ labels: series[closestSeriesIndex].metric,
216
+ timestamp: point[0],
217
+ valuePerSecond: point[1],
218
+ value: point[2],
219
+ duration: point[3],
220
+ x: xScale(point[0]),
221
+ y: yScale(point[1]),
222
+ };
223
+ }, [pos, series, xScale, yScale, margin]);
224
+
225
+ const onMouseDown = (e: React.MouseEvent<SVGSVGElement | HTMLDivElement, MouseEvent>): void => {
226
+ // only left mouse button
227
+ if (e.button !== 0) {
228
+ return;
229
+ }
230
+
231
+ // X/Y coordinate array relative to svg
232
+ const rel = pointer(e);
233
+
234
+ const xCoordinate = rel[0];
235
+ const xCoordinateWithoutMargin = xCoordinate - margin;
236
+ if (xCoordinateWithoutMargin >= 0) {
237
+ setRelPos(xCoordinateWithoutMargin);
238
+ setDragging(true);
239
+ }
240
+
241
+ e.stopPropagation();
242
+ e.preventDefault();
243
+ };
244
+
245
+ const onMouseUp = (e: React.MouseEvent<SVGSVGElement | HTMLDivElement, MouseEvent>): void => {
246
+ setDragging(false);
247
+
248
+ if (relPos === -1) {
249
+ // MouseDown happened outside of this element.
250
+ return;
251
+ }
252
+
253
+ // This is a normal click. We tolerate tiny movements to still be a
254
+ // click as they can occur when clicking based on user feedback.
255
+ if (Math.abs(relPos - pos[0]) <= 1) {
256
+ setRelPos(-1);
257
+ return;
258
+ }
259
+
260
+ let startPos = relPos;
261
+ let endPos = pos[0];
262
+
263
+ if (startPos > endPos) {
264
+ startPos = pos[0];
265
+ endPos = relPos;
266
+ }
267
+
268
+ const startCorrection = 10;
269
+ const endCorrection = 30;
270
+
271
+ const firstTime = xScale.invert(startPos - startCorrection).valueOf();
272
+ const secondTime = xScale.invert(endPos - endCorrection).valueOf();
273
+
274
+ setTimeRange(DateTimeRange.fromAbsoluteDates(firstTime, secondTime));
275
+
276
+ setRelPos(-1);
277
+
278
+ e.stopPropagation();
279
+ e.preventDefault();
280
+ };
281
+
282
+ return (
283
+ <>
284
+ <MetricsContextMenu
285
+ onAddLabelMatcher={addLabelMatcher}
286
+ menuId={MENU_ID}
287
+ highlighted={highlighted}
288
+ trackVisibility={trackVisibility}
289
+ />
290
+
291
+ {highlighted != null && hovering && !dragging && pos[0] !== 0 && pos[1] !== 0 && (
292
+ <div
293
+ onMouseMove={onMouseMove}
294
+ onMouseEnter={() => setHovering(true)}
295
+ onMouseLeave={() => setHovering(false)}
296
+ >
297
+ {!isContextMenuOpen && (
298
+ <MetricsTooltip
299
+ x={pos[0] + margin}
300
+ y={pos[1] + margin}
301
+ highlighted={highlighted}
302
+ contextElement={graph.current}
303
+ sampleUnit="%"
304
+ delta={false}
305
+ />
306
+ )}
307
+ </div>
308
+ )}
309
+ <div
310
+ ref={graph}
311
+ onMouseEnter={function () {
312
+ setHovering(true);
313
+ }}
314
+ onMouseLeave={() => setHovering(false)}
315
+ onContextMenu={displayMenu}
316
+ >
317
+ <svg
318
+ width={`${width}px`}
319
+ height={`${height + margin}px`}
320
+ onMouseDown={onMouseDown}
321
+ onMouseUp={onMouseUp}
322
+ onMouseMove={onMouseMove}
323
+ >
324
+ <g transform={`translate(${margin}, 0)`}>
325
+ {dragging && (
326
+ <g className="zoom-time-rect">
327
+ <rect
328
+ className="bar"
329
+ x={pos[0] - relPos < 0 ? pos[0] : relPos}
330
+ y={0}
331
+ height={height}
332
+ width={Math.abs(pos[0] - relPos)}
333
+ fill={'rgba(0, 0, 0, 0.125)'}
334
+ />
335
+ </g>
336
+ )}
337
+ </g>
338
+ <g transform={`translate(${margin * 1.5}, ${margin / 1.5})`}>
339
+ <g className="y axis" textAnchor="end" fontSize="10" fill="none">
340
+ {yScale.ticks(5).map((d, i, allTicks) => {
341
+ let decimals = 2;
342
+ const intervalBetweenTicks = allTicks[1] - allTicks[0];
343
+
344
+ if (intervalBetweenTicks < 1) {
345
+ const precision = getPrecision(intervalBetweenTicks);
346
+ decimals = precision;
347
+ }
348
+
349
+ return (
350
+ <Fragment key={`${i.toString()}-${d.toString()}`}>
351
+ <g
352
+ key={`tick-${i}`}
353
+ className="tick"
354
+ /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */
355
+ transform={`translate(0, ${yScale(d)})`}
356
+ >
357
+ <line className="stroke-gray-300 dark:stroke-gray-500" x2={-6} />
358
+ <text fill="currentColor" x={-9} dy={'0.32em'}>
359
+ {valueFormatter(d, 'percent', decimals)}
360
+ </text>
361
+ </g>
362
+ <g key={`grid-${i}`}>
363
+ <line
364
+ className="stroke-gray-300 dark:stroke-gray-500"
365
+ x1={xScale(from)}
366
+ x2={xScale(to)}
367
+ y1={yScale(d)}
368
+ y2={yScale(d)}
369
+ />
370
+ </g>
371
+ </Fragment>
372
+ );
373
+ })}
374
+ <line
375
+ className="stroke-gray-300 dark:stroke-gray-500"
376
+ x1={0}
377
+ x2={0}
378
+ y1={0}
379
+ y2={height - margin}
380
+ />
381
+ <line
382
+ className="stroke-gray-300 dark:stroke-gray-500"
383
+ x1={xScale(to)}
384
+ x2={xScale(to)}
385
+ y1={0}
386
+ y2={height - margin}
387
+ />
388
+ <g transform={`translate(${-margin}, ${(height - margin) / 2}) rotate(270)`}>
389
+ <text
390
+ fill="currentColor"
391
+ dy="-0.7em"
392
+ className="text-sm capitalize"
393
+ textAnchor="middle"
394
+ >
395
+ Utilization
396
+ </text>
397
+ </g>
398
+ </g>
399
+ <g
400
+ className="x axis"
401
+ fill="none"
402
+ fontSize="10"
403
+ textAnchor="middle"
404
+ transform={`translate(0,${height - margin})`}
405
+ >
406
+ {xScale.ticks(5).map((d, i) => (
407
+ <Fragment key={`${i.toString()}-${d.toString()}`}>
408
+ <g
409
+ key={`tick-${i}`}
410
+ className="tick"
411
+ /* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */
412
+ transform={`translate(${xScale(d)}, 0)`}
413
+ >
414
+ <line y2={6} className="stroke-gray-300 dark:stroke-gray-500" />
415
+ <text fill="currentColor" dy=".71em" y={9}>
416
+ {formatDate(d, formatForTimespan(from, to), timezone)}
417
+ </text>
418
+ </g>
419
+ <g key={`grid-${i}`}>
420
+ <line
421
+ className="stroke-gray-300 dark:stroke-gray-500"
422
+ x1={xScale(d)}
423
+ x2={xScale(d)}
424
+ y1={0}
425
+ y2={-height + margin}
426
+ />
427
+ </g>
428
+ </Fragment>
429
+ ))}
430
+ <line
431
+ className="stroke-gray-300 dark:stroke-gray-500"
432
+ x1={0}
433
+ x2={graphWidth}
434
+ y1={0}
435
+ y2={0}
436
+ />
437
+ <g transform={`translate(${(width - 2.5 * margin) / 2}, ${margin / 2})`}>
438
+ <text fill="currentColor" dy=".71em" y={5} className="text-sm">
439
+ Time
440
+ </text>
441
+ </g>
442
+ </g>
443
+ <g className="lines fill-transparent">
444
+ {series.map((s, i) => (
445
+ <g key={i} className="line">
446
+ <MetricsSeries
447
+ data={s}
448
+ line={l}
449
+ color={color(i.toString())}
450
+ strokeWidth={
451
+ hovering && highlighted != null && i === highlighted.seriesIndex
452
+ ? lineStrokeHover
453
+ : lineStroke
454
+ }
455
+ xScale={xScale}
456
+ yScale={yScale}
457
+ />
458
+ </g>
459
+ ))}
460
+ </g>
461
+ {hovering && highlighted != null && (
462
+ <g
463
+ className="circle-group"
464
+ ref={metricPointRef}
465
+ style={{fill: color(highlighted.seriesIndex.toString())}}
466
+ >
467
+ <MetricsCircle cx={highlighted.x} cy={highlighted.y} />
468
+ </g>
469
+ )}
470
+ </g>
471
+ </svg>
472
+ </div>
473
+ </>
474
+ );
475
+ };
476
+
477
+ const UtilizationMetrics = ({
478
+ data,
479
+ addLabelMatcher,
480
+ setTimeRange,
481
+ utilizationMetricsLoading,
482
+ }: Props): JSX.Element => {
483
+ const {isDarkMode} = useParcaContext();
484
+ const {width, height, margin, heightStyle} = useMetricsGraphDimensions(false);
485
+
486
+ return (
487
+ <AnimatePresence>
488
+ <motion.div
489
+ className="h-full w-full relative"
490
+ key="utilization-metrics-graph-loaded"
491
+ initial={{display: 'none', opacity: 0}}
492
+ animate={{display: 'block', opacity: 1}}
493
+ transition={{duration: 0.5}}
494
+ >
495
+ {utilizationMetricsLoading === true ? (
496
+ <MetricsGraphSkeleton heightStyle={heightStyle} isDarkMode={isDarkMode} />
497
+ ) : (
498
+ <RawUtilizationMetrics
499
+ data={data}
500
+ addLabelMatcher={addLabelMatcher}
501
+ setTimeRange={setTimeRange}
502
+ width={width}
503
+ height={height}
504
+ margin={margin}
505
+ />
506
+ )}
507
+ </motion.div>
508
+ </AnimatePresence>
509
+ );
510
+ };
511
+
512
+ export default UtilizationMetrics;
@@ -63,7 +63,7 @@ export interface HighlightedSeries {
63
63
  y: number;
64
64
  }
65
65
 
66
- interface Series {
66
+ export interface Series {
67
67
  metric: Label[];
68
68
  values: number[][];
69
69
  labelset: string;
@@ -219,6 +219,7 @@ export const RawMetricsGraph = ({
219
219
 
220
220
  const pointIndex = d3.minIndex(distances);
221
221
  const minDistance = distances[pointIndex];
222
+
222
223
  return {
223
224
  pointIndex,
224
225
  distance: minDistance,
@@ -0,0 +1,94 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {useEffect, useMemo} from 'react';
15
+
16
+ import {RpcError} from '@protobuf-ts/runtime-rpc';
17
+
18
+ import {Duration, QueryRangeResponse, QueryServiceClient, Timestamp} from '@parca/client';
19
+ import {useGrpcMetadata, useURLState} from '@parca/components';
20
+ import {getStepDuration} from '@parca/utilities';
21
+
22
+ import useGrpcQuery from '../../useGrpcQuery';
23
+
24
+ interface IQueryRangeState {
25
+ response: QueryRangeResponse | null;
26
+ isLoading: boolean;
27
+ error: RpcError | null;
28
+ }
29
+
30
+ const getStepCountFromScreenWidth = (pixelsPerPoint: number): number => {
31
+ let width =
32
+ // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
33
+ window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
34
+
35
+ // subtract the padding around the graph
36
+ width = width - (20 + 24 + 68) * 2;
37
+ return Math.round(width / pixelsPerPoint);
38
+ };
39
+
40
+ export const useQueryRange = (
41
+ client: QueryServiceClient,
42
+ queryExpression: string,
43
+ start: number,
44
+ end: number,
45
+ sumBy: string[],
46
+ skip = false
47
+ ): IQueryRangeState => {
48
+ const metadata = useGrpcMetadata();
49
+ const [stepCountStr, setStepCount] = useURLState('step_count');
50
+
51
+ const defaultStepCount = useMemo(() => {
52
+ return getStepCountFromScreenWidth(10);
53
+ }, []);
54
+
55
+ const stepCount = useMemo(() => {
56
+ if (stepCountStr != null) {
57
+ return parseInt(stepCountStr as string, 10);
58
+ }
59
+
60
+ return defaultStepCount;
61
+ }, [stepCountStr, defaultStepCount]);
62
+
63
+ useEffect(() => {
64
+ if (stepCountStr == null) {
65
+ setStepCount(defaultStepCount.toString());
66
+ }
67
+ }, [stepCountStr, defaultStepCount, setStepCount]);
68
+
69
+ const {data, isLoading, error} = useGrpcQuery<QueryRangeResponse | undefined>({
70
+ key: ['query-range', queryExpression, start, end, (sumBy ?? []).join(','), stepCount, metadata],
71
+ queryFn: async () => {
72
+ const stepDuration = getStepDuration(start, end, stepCount);
73
+ const {response} = await client.queryRange(
74
+ {
75
+ query: queryExpression,
76
+ start: Timestamp.fromDate(new Date(start)),
77
+ end: Timestamp.fromDate(new Date(end)),
78
+ step: Duration.create(stepDuration),
79
+ limit: 0,
80
+ sumBy,
81
+ },
82
+ {meta: metadata}
83
+ );
84
+ return response;
85
+ },
86
+ options: {
87
+ retry: false,
88
+ enabled: !skip,
89
+ staleTime: 1000 * 60 * 5, // 5 minutes
90
+ },
91
+ });
92
+
93
+ return {isLoading, error: error as RpcError | null, response: data ?? null};
94
+ };