Show Impact metric in the Operations list on the Insights page. (#6389)

This commit is contained in:
Kamil Kisiela 2025-01-21 10:39:37 +01:00 committed by GitHub
parent ec356a7784
commit 781b140ffb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 141 additions and 80 deletions

View file

@ -0,0 +1,12 @@
---
'hive': minor
---
Show Impact metric in the Operations list on the Insights page.
Impact equals to the total time spent on this operation in the selected period in seconds.
It helps assess which operations contribute the most to overall latency.
```
Impact = Requests * avg/1000
```

View file

@ -28,6 +28,7 @@ export interface OperationsStatsMapper {
clients: readonly string[];
}
export interface DurationValuesMapper {
avg: number | null;
p75: number | null;
p90: number | null;
p95: number | null;

View file

@ -136,6 +136,7 @@ export default gql`
}
type DurationValues {
avg: Int!
p75: Int!
p90: Int!
p95: Int!

View file

@ -715,7 +715,7 @@ export class OperationsManager {
}
@cache<{ period: DateRange } & TargetSelector>(selector => JSON.stringify(selector))
async readDetailedDurationPercentiles({
async readDetailedDurationMetrics({
period,
organizationId: organization,
projectId: project,
@ -744,7 +744,7 @@ export class OperationsManager {
},
});
return this.reader.durationPercentiles({
return this.reader.durationMetrics({
target,
period,
operations,

View file

@ -32,28 +32,21 @@ function toUnixTimestamp(utcDate: string): any {
return new UTCDate(iso).getTime();
}
export interface Percentiles {
export interface DurationMetrics {
avg: number;
p75: number;
p90: number;
p95: number;
p99: number;
}
function toPercentiles(item: Percentiles | number[]) {
if (Array.isArray(item)) {
return {
p75: item[0],
p90: item[1],
p95: item[2],
p99: item[3],
};
}
function toDurationMetrics(percentiles: [number, number, number, number], avg: number) {
return {
p75: item.p75,
p90: item.p90,
p95: item.p95,
p99: item.p99,
avg,
p75: percentiles[0],
p90: percentiles[1],
p95: percentiles[2],
p99: percentiles[3],
};
}
@ -575,7 +568,7 @@ export class OperationsReader {
operation_kind: string;
}>({
query: sql`
SELECT
SELECT
name,
hash,
operation_kind
@ -651,7 +644,7 @@ export class OperationsReader {
type: 'query' | 'mutation' | 'subscription';
}>({
query: sql`
SELECT
SELECT
"operation_collection_details"."hash" AS "hash",
"operation_collection_details"."operation_kind" AS "type",
"operation_collection_details"."name" AS "name",
@ -697,7 +690,7 @@ export class OperationsReader {
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
coordinate
FROM ${aggregationTableName('coordinates')}
${this.createFilter({
@ -747,7 +740,7 @@ export class OperationsReader {
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
sum(total) as total,
client_name,
client_version
@ -844,7 +837,7 @@ export class OperationsReader {
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
sum(total) as total,
client_version
FROM ${aggregationTableName('clients')}
@ -892,7 +885,7 @@ export class OperationsReader {
SELECT
SUM("result"."total") AS "amountOfRequests"
FROM (
SELECT
SELECT
SUM("operations_daily"."total") AS "total"
FROM
"operations_daily"
@ -904,7 +897,7 @@ export class OperationsReader {
UNION ALL
SELECT
SELECT
SUM("subscription_operations_daily"."total") AS "total"
FROM
"subscription_operations_daily"
@ -1005,7 +998,7 @@ export class OperationsReader {
SELECT
"operation_collection_details"."name",
"operation_collection_details"."hash"
FROM
FROM
"operation_collection_details"
PREWHERE
"operation_collection_details"."target" IN (${sql.array(args.targetIds, 'String')})
@ -1262,7 +1255,7 @@ export class OperationsReader {
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
count(distinct client_version) as total
FROM ${aggregationTableName('clients')}
${this.createFilter({
@ -1470,7 +1463,7 @@ export class OperationsReader {
client_name: string;
}>({
query: sql`
SELECT
SELECT
sum(total) as count,
client_name
FROM clients_daily
@ -1573,7 +1566,7 @@ export class OperationsReader {
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
toDateTime(
intDiv(
toUnixTimestamp(timestamp),
@ -1585,7 +1578,7 @@ export class OperationsReader {
FROM ${aggregationTableName('operations')}
${this.createFilter({ target: targets, period: roundedPeriod })}
GROUP BY target, date
ORDER BY
ORDER BY
target,
date
WITH FILL
@ -1723,7 +1716,7 @@ export class OperationsReader {
}): Promise<
Array<{
date: any;
duration: Percentiles;
duration: DurationMetrics;
}>
> {
return this.getDurationAndCountOverTime({
@ -1745,13 +1738,15 @@ export class OperationsReader {
period: DateRange;
operations?: readonly string[];
clients?: readonly string[];
}): Promise<Percentiles> {
}): Promise<DurationMetrics> {
const result = await this.clickHouse.query<{
percentiles: [number, number, number, number];
average: number;
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
avgMerge(duration_avg) as average,
quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles
FROM ${aggregationTableName('operations')}
${this.createFilter({ target, period, operations, clients })}
@ -1762,10 +1757,10 @@ export class OperationsReader {
}),
);
return toPercentiles(result.data[0].percentiles);
return toDurationMetrics(result.data[0].percentiles, result.data[0].average);
}
async durationPercentiles({
async durationMetrics({
target,
period,
operations,
@ -1780,12 +1775,14 @@ export class OperationsReader {
}) {
const result = await this.clickHouse.query<{
hash: string;
average: number;
percentiles: [number, number, number, number];
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
hash,
avgMerge(duration_avg) as average,
quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles
FROM ${aggregationTableName('operations')}
${this.createFilter({
@ -1813,10 +1810,10 @@ export class OperationsReader {
}),
);
const collection = new Map<string, Percentiles>();
const collection = new Map<string, DurationMetrics>();
result.data.forEach(row => {
collection.set(row.hash, toPercentiles(row.percentiles));
collection.set(row.hash, toDurationMetrics(row.percentiles, row.average));
});
return collection;
@ -1881,6 +1878,7 @@ export class OperationsReader {
return sql`
SELECT
date,
average,
percentiles,
total,
totalOk
@ -1892,6 +1890,7 @@ export class OperationsReader {
toUInt32(${String(interval.seconds)})
) * toUInt32(${String(interval.seconds)})
) as date,
avgMerge(duration_avg) as average,
quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles,
sum(total) as total,
sum(total_ok) as totalOk
@ -1929,6 +1928,7 @@ export class OperationsReader {
date: string;
total: number;
totalOk: number;
average: number;
percentiles: [number, number, number, number];
}>(query);
@ -1937,7 +1937,7 @@ export class OperationsReader {
date: toUnixTimestamp(row.date),
total: ensureNumber(row.total),
totalOk: ensureNumber(row.totalOk),
duration: toPercentiles(row.percentiles),
duration: toDurationMetrics(row.percentiles, row.average),
};
});
}
@ -2148,7 +2148,7 @@ export class OperationsReader {
}>(
this.pickAggregationByPeriod({
query: aggregationTableName => sql`
SELECT
SELECT
toDateTime(
intDiv(
toUnixTimestamp(timestamp),

View file

@ -45,7 +45,7 @@ export const ClientStats: ClientStatsResolvers = {
period,
clients: clientName === 'unknown' ? ['unknown', ''] : [clientName],
}),
operationsManager.readDetailedDurationPercentiles({
operationsManager.readDetailedDurationMetrics({
organizationId: organization,
projectId: project,
targetId: target,

View file

@ -2,6 +2,9 @@ import { nsToMs } from '../../../shared/helpers';
import type { DurationValuesResolvers } from './../../../__generated__/types';
export const DurationValues: DurationValuesResolvers = {
avg: value => {
return transformPercentile(value.avg);
},
p75: value => {
return transformPercentile(value.p75);
},

View file

@ -18,7 +18,7 @@ export const OperationsStats: OperationsStatsResolvers = {
operations: operationsFilter,
clients,
}),
operationsManager.readDetailedDurationPercentiles({
operationsManager.readDetailedDurationMetrics({
organizationId: organization,
projectId: project,
targetId: target,

View file

@ -40,7 +40,7 @@ export const SchemaCoordinateStats: SchemaCoordinateStatsResolvers = {
period,
schemaCoordinate,
}),
operationsManager.readDetailedDurationPercentiles({
operationsManager.readDetailedDurationMetrics({
organizationId: organization,
projectId: project,
targetId: target,

View file

@ -1,11 +1,13 @@
import { ReactElement, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
import clsx from 'clsx';
import { InfoIcon } from 'lucide-react';
import { useQuery } from 'urql';
import { useDebouncedCallback } from 'use-debounce';
import { Scale, Section } from '@/components/common';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sortable, Table, TBody, Td, Th, THead, Tooltip, Tr } from '@/components/v2';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Sortable, Table, TBody, Td, Th, THead, Tr } from '@/components/v2';
import { env } from '@/env/frontend';
import { FragmentType, graphql, useFragment } from '@/gql';
import { DateRangeInput } from '@/gql/graphql';
@ -34,6 +36,7 @@ interface Operation {
failureRate: number;
requests: number;
percentage: number;
impact: number;
hash: string;
}
@ -56,6 +59,9 @@ function OperationRow({
const p90 = useFormattedDuration(operation.p90);
const p95 = useFormattedDuration(operation.p95);
const p99 = useFormattedDuration(operation.p99);
const impact = useFormattedNumber(
operation.impact < 1000 ? Math.round(operation.impact * 100) / 100 : operation.impact,
);
return (
<>
@ -83,11 +89,16 @@ function OperationRow({
</Link>
</Button>
{operation.name === 'anonymous' && (
<Tooltip.Provider delayDuration={200}>
<Tooltip content="Anonymous operation detected. Naming your operations is a recommended practice">
<ExclamationTriangleIcon className="text-yellow-500" />
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger>
<ExclamationTriangleIcon className="text-yellow-500" />
</TooltipTrigger>
<TooltipContent>
Anonymous operation detected. Naming your operations is a recommended practice
</TooltipContent>
</Tooltip>
</Tooltip.Provider>
</TooltipProvider>
)}
</div>
</Td>
@ -99,6 +110,7 @@ function OperationRow({
<Td align="center">{p99}</Td>
<Td align="center">{failureRate}%</Td>
<Td align="center">{count}</Td>
<Td align="center">{impact}</Td>
<Td align="right">{percentage}%</Td>
<Td>
<Scale value={operation.percentage} size={10} max={100} className="justify-end" />
@ -155,6 +167,12 @@ const columns = [
align: 'center',
},
}),
columnHelper.accessor('impact', {
header: 'Impact',
meta: {
align: 'center',
},
}),
columnHelper.accessor('percentage', {
header: 'Traffic',
meta: {
@ -222,7 +240,7 @@ function OperationsTable({
<Table>
<THead>
<Tooltip.Provider>
<TooltipProvider>
{headers.map(header => {
const canSort = header.column.getCanSort();
const align: 'center' | 'left' | 'right' =
@ -230,21 +248,39 @@ function OperationsTable({
const name = flexRender(header.column.columnDef.header, header.getContext());
return (
<Th key={header.id} className="text-sm font-semibold" align={align}>
{canSort ? (
<Sortable
sortOrder={header.column.getIsSorted()}
onClick={header.column.getToggleSortingHandler()}
otherColumnSorted={sortedColumnsById.some(id => id !== header.id)}
>
{name}
</Sortable>
) : (
name
)}
<div className="inline-flex items-center gap-x-2">
{canSort ? (
<Sortable
sortOrder={header.column.getIsSorted()}
onClick={header.column.getToggleSortingHandler()}
otherColumnSorted={sortedColumnsById.some(id => id !== header.id)}
>
{name}
</Sortable>
) : (
name
)}
{header.column.columnDef.header === 'Impact' ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="size-4 text-gray-400" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px] text-left text-sm">
<p className="mb-4">
Equals to the total time spent on this operation in the selected
period in seconds.
</p>
<code>Impact = Requests * avg/1000</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
</Th>
);
})}
</Tooltip.Provider>
</TooltipProvider>
</THead>
<TBody>
{tableInstance
@ -326,6 +362,7 @@ const OperationsTableContainer_OperationsStatsFragment = graphql(`
p90
p95
p99
avg
}
countOk
count
@ -381,6 +418,7 @@ function OperationsTableContainer({
failureRate: (1 - op.countOk / op.count) * 100,
requests: op.count,
percentage: op.percentage,
impact: op.duration.avg > 0 ? op.count * (op.duration.avg / 1000) : 0,
hash: op.operationHash!,
});
}

View file

@ -1,14 +1,9 @@
import { ComponentProps, ReactElement, ReactNode } from 'react';
import { Tooltip } from '@/components/v2/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { TriangleUpIcon } from '@radix-ui/react-icons';
import { SortDirection } from '@tanstack/react-table';
export function Sortable({
children,
sortOrder,
otherColumnSorted,
onClick,
}: {
export function Sortable(props: {
children: ReactNode;
sortOrder: SortDirection | false;
/**
@ -16,28 +11,39 @@ export function Sortable({
* It's used to show a different tooltip when sorting by multiple columns.
*/
otherColumnSorted?: boolean;
onClick: ComponentProps<'button'>['onClick'];
onClick?: ComponentProps<'button'>['onClick'];
}): ReactElement {
const tooltipText =
sortOrder === false
? 'Click to sort descending' + otherColumnSorted
props.sortOrder === false
? 'Click to sort descending' + props.otherColumnSorted
? ' (hold shift to sort by multiple columns)'
: ''
: {
asc: 'Click to cancel sorting',
desc: 'Click to sort ascending',
}[sortOrder];
}[props.sortOrder];
return (
<Tooltip content={tooltipText}>
<button className="flex items-center justify-center" onClick={onClick}>
<div>{children}</div>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center justify-center"
onClick={e => {
e.stopPropagation();
props.onClick?.(e);
}}
>
<div>{props.children}</div>
{sortOrder === 'asc' ? <TriangleUpIcon className="ml-2 text-orange-500" /> : null}
{sortOrder === 'desc' ? (
<TriangleUpIcon className="ml-2 rotate-180 text-orange-500" />
) : null}
</button>
</Tooltip>
{props.sortOrder === 'asc' ? <TriangleUpIcon className="ml-2 text-orange-500" /> : null}
{props.sortOrder === 'desc' ? (
<TriangleUpIcon className="ml-2 rotate-180 text-orange-500" />
) : null}
</button>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}