mirror of
https://github.com/graphql-hive/console
synced 2026-05-24 01:28:32 +00:00
Show Impact metric in the Operations list on the Insights page. (#6389)
This commit is contained in:
parent
ec356a7784
commit
781b140ffb
11 changed files with 141 additions and 80 deletions
12
.changeset/short-donkeys-live.md
Normal file
12
.changeset/short-donkeys-live.md
Normal 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
|
||||
```
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export default gql`
|
|||
}
|
||||
|
||||
type DurationValues {
|
||||
avg: Int!
|
||||
p75: Int!
|
||||
p90: Int!
|
||||
p95: Int!
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const ClientStats: ClientStatsResolvers = {
|
|||
period,
|
||||
clients: clientName === 'unknown' ? ['unknown', ''] : [clientName],
|
||||
}),
|
||||
operationsManager.readDetailedDurationPercentiles({
|
||||
operationsManager.readDetailedDurationMetrics({
|
||||
organizationId: organization,
|
||||
projectId: project,
|
||||
targetId: target,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
operations: operationsFilter,
|
||||
clients,
|
||||
}),
|
||||
operationsManager.readDetailedDurationPercentiles({
|
||||
operationsManager.readDetailedDurationMetrics({
|
||||
organizationId: organization,
|
||||
projectId: project,
|
||||
targetId: target,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const SchemaCoordinateStats: SchemaCoordinateStatsResolvers = {
|
|||
period,
|
||||
schemaCoordinate,
|
||||
}),
|
||||
operationsManager.readDetailedDurationPercentiles({
|
||||
operationsManager.readDetailedDurationMetrics({
|
||||
organizationId: organization,
|
||||
projectId: project,
|
||||
targetId: target,
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue